diff --git a/src/gui/src/UI/Dashboard/TabFiles.js b/src/gui/src/UI/Dashboard/TabFiles.js
index 07c9ff016..d3e771f65 100644
--- a/src/gui/src/UI/Dashboard/TabFiles.js
+++ b/src/gui/src/UI/Dashboard/TabFiles.js
@@ -33,6 +33,7 @@ import new_context_menu_item from '../../helpers/new_context_menu_item.js';
import publish_as_website from '../../helpers/publish_as_website.js';
import ContextMenuModal, { isTouchPrimaryDevice } from './ContextMenu/ContextMenu.js';
import UIItemPropertiesModal from './UIItemPropertiesModal.js';
+import UIShareModal from './UIShareModal.js';
import { dedupedName } from './dedupedName.js';
import { isEntryVisible, isHiddenName, showHiddenFiles } from './hiddenFiles.js';
@@ -3882,6 +3883,16 @@ const TabFiles = {
$container: _this.$el_window,
});
},
+ onShare: ({ name, path: item_path }) => {
+ // Dashboard uses a responsive modal instead of the desktop UIWindow.
+ UIShareModal({
+ name,
+ path: item_path,
+ // The row's fs entry, so the modal can show the item's icon.
+ fsentry: options,
+ $container: _this.$el_window,
+ });
+ },
});
return menu_items;
diff --git a/src/gui/src/UI/Dashboard/UIShareModal.js b/src/gui/src/UI/Dashboard/UIShareModal.js
new file mode 100644
index 000000000..983ead642
--- /dev/null
+++ b/src/gui/src/UI/Dashboard/UIShareModal.js
@@ -0,0 +1,377 @@
+/*
+ * Copyright (C) 2024-present Puter Technologies Inc.
+ *
+ * This file is part of Puter.
+ *
+ * Puter is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero 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 Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import path from '../../lib/path.js';
+import item_icon from '../../helpers/item_icon.js';
+import { owner_of_path } from '../../helpers/path_owner.js';
+import { invalidate_shared_roots } from '../../helpers/shared_access.js';
+import { icons } from '../../helpers/actionIcons.js';
+
+const { html_encode } = window;
+
+const closeIcon = ` `;
+
+// Offered when granting. The API accepts `see` and `list` too, but they are a
+// developer-level distinction with no place in this dialog — a row already set
+// to one is shown as-is rather than quietly rounded up to `read`.
+const MODES = ['read', 'write', 'manage'];
+
+// Raw (unencoded) text: i18n() html-encodes by default, and callers wrap this
+// in html_encode() — leaving that on would render "&" as a literal "&".
+const mode_label = (mode) => {
+ if ( mode === 'write' ) return i18n('share_access_write', [], false);
+ if ( mode === 'manage' ) return i18n('share_access_manage', [], false);
+ if ( mode === 'read' ) return i18n('share_access_read', [], false);
+ return mode;
+};
+
+const options_for = (current) => {
+ const modes = MODES.includes(current) ? MODES : [current, ...MODES];
+ return modes
+ .map(
+ (mode) =>
+ `${html_encode(mode_label(mode))} `,
+ )
+ .join('');
+};
+
+// Stable, name-derived hue so a person keeps the same avatar color across
+// rows and reopenings. The CSS derives both fill and glyph color from it.
+const hue_for = (name) => {
+ let hue = 0;
+ for ( let i = 0; i < name.length; i++ ) {
+ hue = (hue * 31 + name.charCodeAt(i)) % 360;
+ }
+ return hue;
+};
+
+const avatar_html = (name) => {
+ const initial = (name || '?').trim().charAt(0).toUpperCase() || '?';
+ return `${html_encode(initial)} `;
+};
+
+/**
+ * A responsive, from-scratch sharing modal for the Dashboard's Files tab.
+ * Unlike UIWindowShare (which spawns a desktop UIWindow), this renders a
+ * self-contained overlay that behaves as a centered card on desktop and a
+ * bottom sheet on mobile, styled with the dashboard's design tokens.
+ *
+ * Feature-equivalent to UIWindowShare: grant access by email or username,
+ * list who has access (owner, direct grants, grants inherited from an
+ * ancestor folder), change a grant's mode, and revoke — with the revoke
+ * confirmation inlined into the row instead of a stacked alert window.
+ *
+ * @param {Object} opts
+ * @param {string} opts.path - Full path of the item to share
+ * @param {string} [opts.name] - Display name; defaults to the path's basename
+ * @param {string} [opts.owner] - Owner's username; defaults to the first path
+ * segment, which is not the current user when a `manage` recipient opens this
+ * @param {Object} [opts.fsentry] - The item's fs entry, used to render its icon
+ * @param {jQuery} [opts.$container] - Element to append the overlay to (defaults to
)
+ * @returns {{ close: () => void }}
+ */
+export default function UIShareModal ({ path: item_path, name, owner, fsentry, $container }) {
+ const $root = $container && $container.length ? $container : $('body');
+ const item_name = name ?? path.basename(item_path);
+ const item_owner = owner ?? owner_of_path(item_path) ?? window.user.username;
+
+ const $overlay = $(`
+
+
+
+
+
+
+
${i18n('share_who_has_access')}
+
+
+
+
+ `);
+
+ $root.append($overlay);
+
+ // Reveal after paint so the CSS transition (fade + scale/slide) runs.
+ requestAnimationFrame(() => $overlay.addClass('share-modal-show'));
+
+ const $status = $overlay.find('.share-modal-status');
+ const $list = $overlay.find('.share-modal-list');
+ const $recipient = $overlay.find('.share-modal-recipient');
+ const $submit = $overlay.find('.share-modal-submit');
+
+ // Focus returns to wherever the user was (usually the shared row) when
+ // the modal closes; the input takes it while the modal is up.
+ const el_previous_focus = document.activeElement;
+ $recipient.get(0)?.focus({ preventScroll: true });
+
+ let closed = false;
+ const close = () => {
+ if ( closed ) return;
+ closed = true;
+ $overlay.removeClass('share-modal-show');
+ $(document).off('keydown.share-modal');
+ setTimeout(() => $overlay.remove(), 200);
+ if ( el_previous_focus && document.contains(el_previous_focus) ) {
+ try {
+ el_previous_focus.focus({ preventScroll: true });
+ } catch { /* focus restoration is best-effort */ }
+ }
+ };
+
+ // The last successfully fetched share list, so canceling an inline revoke
+ // confirmation can restore the row without another network round-trip.
+ let last_shares = [];
+
+ const show_error = (message) => {
+ $status
+ .removeClass('share-modal-status-success')
+ .addClass('share-modal-status-error')
+ .html(html_encode(message));
+ };
+
+ const show_success = (message_html) => {
+ $status
+ .removeClass('share-modal-status-error')
+ .addClass('share-modal-status-success')
+ .html(message_html);
+ };
+
+ const clear_status = () => {
+ $status.removeClass('share-modal-status-error share-modal-status-success').empty();
+ };
+
+ const render = (shares) => {
+ last_shares = shares;
+ let rows = '';
+
+ // The owner's access comes from owning the item, so it can't be revoked
+ rows += '';
+ rows += avatar_html(item_owner);
+ rows += `${html_encode(item_owner)}${item_owner === window.user.username ? ` (${i18n('share_you')})` : ''} `;
+ rows += `${i18n('share_owner')} `;
+ rows += '
';
+
+ for ( const share of shares ) {
+ const holder = html_encode(share.holder ?? '');
+ const you_suffix = share.holder === window.user.username ? ` (${i18n('share_you')})` : '';
+ if ( share.inheritedFrom ) {
+ // Granted on an ancestor, so it can only be changed there
+ rows += '';
+ rows += avatar_html(share.holder ?? '');
+ rows += `${holder}${you_suffix} ${i18n('share_inherited_via', { folder: path.basename(share.inheritedFrom) })} `;
+ rows += `${html_encode(mode_label(share.mode))} `;
+ rows += '
';
+ continue;
+ }
+ rows += '';
+ rows += avatar_html(share.holder ?? '');
+ rows += `${holder}${you_suffix} `;
+ rows += `${options_for(share.mode)} `;
+ rows += `${icons.trash} `;
+ rows += '
';
+ }
+ if ( !shares.length ) {
+ rows += `${i18n('share_no_one')}
`;
+ }
+ $list.attr('aria-busy', 'false').html(rows);
+ };
+
+ const refresh = async () => {
+ try {
+ render(await puter.fs.getShares(item_path));
+ } catch (e) {
+ $list.attr('aria-busy', 'false').empty();
+ show_error(e?.message ?? i18n('share_failed', [], false));
+ }
+ };
+
+ // -- Dismissal wiring --
+ $overlay.on('click', '.share-modal-close', close);
+ // Backdrop close goes by where the press STARTED: a drag that begins in
+ // the recipient input (text selection) and releases over the backdrop
+ // registers its click on the overlay, and must not eat the typed name.
+ let backdrop_pressed = false;
+ $overlay.on('mousedown', function (e) {
+ backdrop_pressed = e.target === $overlay[0];
+ });
+ $overlay.on('click', function (e) {
+ if ( e.target === $overlay[0] && backdrop_pressed ) close();
+ });
+ $(document).on('keydown.share-modal', function (e) {
+ if ( e.key !== 'Escape' ) return;
+ // An open revoke confirmation swallows the first Escape.
+ if ( $list.find('.share-modal-row-confirm').length ) {
+ render(last_shares);
+ return;
+ }
+ close();
+ });
+
+ // Keep Tab cycling inside the dialog while it's up.
+ $overlay.on('keydown', function (e) {
+ if ( e.key !== 'Tab' ) return;
+ const focusables = $overlay
+ .find('button, input, select, [tabindex]:not([tabindex="-1"])')
+ .filter(':visible:not(:disabled)');
+ if ( !focusables.length ) return;
+ const first = focusables.get(0);
+ const last = focusables.get(focusables.length - 1);
+ if ( e.shiftKey && document.activeElement === first ) {
+ e.preventDefault();
+ last.focus();
+ } else if ( !e.shiftKey && document.activeElement === last ) {
+ e.preventDefault();
+ first.focus();
+ }
+ });
+
+ // -- Item icon (best-effort) --
+ if ( fsentry ) {
+ (async () => {
+ try {
+ const icon = await item_icon(fsentry);
+ if ( !closed && icon?.image ) {
+ $overlay.find('.share-modal-title-icon')
+ .html(` `);
+ }
+ } catch { /* icon is best-effort */ }
+ })();
+ }
+
+ // -- Grant access --
+ $recipient.on('input', function () {
+ $submit.prop('disabled', $(this).val().trim() === '');
+ // Typing again retires a stale success/error message.
+ clear_status();
+ });
+
+ $overlay.on('submit', '.share-modal-add', async function (e) {
+ e.preventDefault();
+ const recipient = $recipient.val().trim();
+ if ( !recipient ) return;
+
+ $submit.prop('disabled', true).addClass('share-modal-btn-busy');
+ try {
+ await puter.fs.share({
+ path: item_path,
+ recipient,
+ mode: $overlay.find('.share-modal-mode').val(),
+ });
+ $recipient.val('');
+ show_success(i18n('share_shared_with', { recipient }));
+ invalidate_shared_roots();
+ await refresh();
+ $recipient.get(0)?.focus({ preventScroll: true });
+ } catch (err) {
+ show_error(err?.message ?? i18n('share_failed', [], false));
+ $submit.prop('disabled', false);
+ } finally {
+ $submit.removeClass('share-modal-btn-busy');
+ }
+ });
+
+ // -- Change a grant's mode --
+ $overlay.on('change', '.share-modal-row-mode', async function () {
+ const holder = $(this).attr('data-holder');
+ const mode = $(this).val();
+ $(this).prop('disabled', true);
+ try {
+ await puter.fs.share({ path: item_path, recipient: holder, mode });
+ show_success(i18n('share_shared_with', { recipient: holder }));
+ invalidate_shared_roots();
+ await refresh();
+ } catch (err) {
+ show_error(err?.message ?? i18n('share_failed', [], false));
+ invalidate_shared_roots();
+ await refresh();
+ }
+ });
+
+ // -- Revoke, confirmed inline in the row --
+ $overlay.on('click', '.share-modal-revoke', function () {
+ const holder = $(this).attr('data-holder');
+ const enc = html_encode(holder);
+ // One confirmation at a time: opening a second one restores the first
+ // row. The re-render replaces every row, so re-find this holder's
+ // instead of using the (now detached) clicked button.
+ if ( $list.find('.share-modal-row-confirm').length ) {
+ render(last_shares);
+ }
+ $list.find('.share-modal-revoke')
+ .filter((_, el) => $(el).attr('data-holder') === holder)
+ .closest('.share-modal-row')
+ .replaceWith(`
+
+ ${i18n('share_confirm_remove', { recipient: holder })}
+
+ ${i18n('cancel')}
+ ${i18n('share_remove')}
+
+
+ `);
+ $list.find('.share-modal-confirm-cancel').trigger('focus');
+ });
+
+ $overlay.on('click', '.share-modal-confirm-cancel', function () {
+ const holder = $(this).closest('.share-modal-row-confirm').attr('data-holder');
+ render(last_shares);
+ $list.find('.share-modal-revoke')
+ .filter((_, el) => $(el).attr('data-holder') === holder)
+ .trigger('focus');
+ });
+
+ $overlay.on('click', '.share-modal-confirm-remove', async function () {
+ const holder = $(this).attr('data-holder');
+ $(this).closest('.share-modal-row-confirm').find('button').prop('disabled', true);
+ try {
+ await puter.fs.unshare(item_path, holder);
+ show_success(i18n('share_access_removed', { recipient: holder }));
+ invalidate_shared_roots();
+ await refresh();
+ } catch (err) {
+ show_error(err?.message ?? i18n('share_failed', [], false));
+ render(last_shares);
+ }
+ });
+
+ refresh();
+
+ return { close };
+}
diff --git a/src/gui/src/UI/UIWindowShare.js b/src/gui/src/UI/UIWindowShare.js
index 2b58f3c3d..6dccb20ce 100644
--- a/src/gui/src/UI/UIWindowShare.js
+++ b/src/gui/src/UI/UIWindowShare.js
@@ -29,10 +29,12 @@ import { icons } from '../helpers/actionIcons.js';
// to one is shown as-is rather than quietly rounded up to `read`.
const MODES = ['read', 'write', 'manage'];
+// Raw (unencoded) text: i18n() html-encodes by default, and callers wrap this
+// in html_encode() — leaving that on would render "&" as a literal "&".
const mode_label = (mode) => {
- if ( mode === 'write' ) return i18n('share_access_write');
- if ( mode === 'manage' ) return i18n('share_access_manage');
- if ( mode === 'read' ) return i18n('share_access_read');
+ if ( mode === 'write' ) return i18n('share_access_write', [], false);
+ if ( mode === 'manage' ) return i18n('share_access_manage', [], false);
+ if ( mode === 'read' ) return i18n('share_access_read', [], false);
return mode;
};
diff --git a/src/gui/src/css/dashboard.css b/src/gui/src/css/dashboard.css
index 74510112b..f8a81ec9d 100644
--- a/src/gui/src/css/dashboard.css
+++ b/src/gui/src/css/dashboard.css
@@ -2254,6 +2254,504 @@ p.myapps-add-error {
}
}
+/* -- Share modal --
+ Responsive, from-scratch replacement for UIWindowShare in the Dashboard:
+ a centered card on desktop, a bottom sheet on mobile. Mirrors the item
+ properties modal's shell so the two read as one system. */
+.share-modal-overlay {
+ position: fixed;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 24px;
+ box-sizing: border-box;
+ background: var(--dashboard-shadow-overlay, rgba(0, 0, 0, 0.5));
+ opacity: 0;
+ transition: opacity 200ms ease;
+ z-index: 1000;
+}
+.share-modal-overlay.share-modal-show {
+ opacity: 1;
+}
+
+.share-modal {
+ display: flex;
+ flex-direction: column;
+ width: min(440px, 100%);
+ max-height: min(620px, 85vh);
+ background: var(--dashboard-card-background);
+ border: 1px solid var(--dashboard-border);
+ border-radius: 14px;
+ box-shadow: 0 12px 40px var(--dashboard-shadow-medium);
+ overflow: hidden;
+ transform: scale(0.96);
+ opacity: 0;
+ transition: transform 200ms ease, opacity 200ms ease;
+}
+.share-modal-overlay.share-modal-show .share-modal {
+ transform: scale(1);
+ opacity: 1;
+}
+
+/* Header */
+.share-modal-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 16px 18px;
+ border-bottom: 1px solid var(--dashboard-border);
+ flex-shrink: 0;
+}
+.share-modal-title {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ min-width: 0;
+}
+.share-modal-title-icon {
+ flex-shrink: 0;
+ width: 34px;
+ height: 34px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+/* Until (and unless) the item's icon resolves, the title sits flush left. */
+.share-modal-title-icon:empty {
+ display: none;
+}
+.share-modal-title-icon img {
+ width: 34px;
+ height: 34px;
+ object-fit: contain;
+}
+.share-modal-title-text {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+}
+.share-modal-title-name {
+ font-size: 15px;
+ font-weight: 600;
+ color: var(--dashboard-text-primary);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.share-modal-title-sub {
+ font-size: 12px;
+ color: var(--dashboard-text-muted);
+}
+.share-modal-close {
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ padding: 0;
+ border: none;
+ border-radius: 8px;
+ background: transparent;
+ color: var(--dashboard-text-secondary);
+ cursor: pointer;
+ transition: background 0.15s, color 0.15s;
+}
+@media (hover: hover) {
+ .share-modal-close:hover {
+ background: var(--dashboard-hover);
+ color: var(--dashboard-text-primary);
+ }
+}
+.share-modal-close:focus-visible {
+ outline: none;
+ box-shadow: 0 0 0 3px var(--select-ring);
+}
+
+/* Body */
+.share-modal-body {
+ flex: 1 1 auto;
+ overflow-y: auto;
+ padding: 16px 18px 18px;
+}
+
+/* Add people. style.css's input[type=text] / select rules match these
+ controls at the SAME specificity, so everything must be spelled out here
+ (twice for focus — input[type=text]:focus sets padding 7px / border 2px).
+ Same trap input.myapps-group-name documents. */
+.share-modal-add-row {
+ display: flex;
+ gap: 8px;
+}
+input.share-modal-recipient {
+ -webkit-appearance: none;
+ appearance: none;
+ box-sizing: border-box;
+ flex: 1 1 auto;
+ width: auto;
+ min-width: 0;
+ padding: 8px 10px;
+ border: 1px solid var(--dashboard-border);
+ border-radius: 8px;
+ background: var(--dashboard-input-background);
+ font-size: 13.5px;
+ font-family: inherit;
+ color: var(--dashboard-text-primary);
+ outline: none;
+ transition: border-color 0.15s, box-shadow 0.15s;
+}
+input.share-modal-recipient::placeholder {
+ color: var(--dashboard-text-muted);
+}
+input.share-modal-recipient:focus {
+ padding: 8px 10px;
+ border: 1px solid var(--select-color);
+ box-shadow: 0 0 0 3px var(--select-ring);
+}
+select.share-modal-mode,
+select.share-modal-row-mode {
+ -webkit-appearance: none;
+ appearance: none;
+ box-sizing: border-box;
+ width: auto;
+ padding: 8px 26px 8px 10px;
+ border: 1px solid var(--dashboard-border);
+ border-radius: 8px;
+ background-color: var(--dashboard-card-background);
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: right 8px center;
+ font-size: 13px;
+ font-family: inherit;
+ color: var(--dashboard-text-primary);
+ cursor: pointer;
+ outline: none;
+ transition: border-color 0.15s, box-shadow 0.15s, background-color 0.15s;
+}
+select.share-modal-mode {
+ flex-shrink: 0;
+}
+select.share-modal-mode:focus,
+select.share-modal-row-mode:focus {
+ padding: 8px 26px 8px 10px;
+ border: 1px solid var(--select-color);
+ box-shadow: 0 0 0 3px var(--select-ring);
+}
+select.share-modal-mode:disabled,
+select.share-modal-row-mode:disabled {
+ opacity: 0.55;
+ cursor: default;
+}
+
+/* to prevent auto-zoom on input focus in mobile — .device-phone's global
+ 17px rule loses to these selectors, so restate the threshold */
+.device-phone input.share-modal-recipient,
+.device-phone input.share-modal-recipient:focus,
+.device-phone select.share-modal-mode,
+.device-phone select.share-modal-row-mode {
+ font-size: 16px;
+}
+
+.share-modal-submit {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ width: 100%;
+ margin-top: 10px;
+ padding: 0 16px;
+ height: 38px;
+ border: 1px solid var(--dashboard-link);
+ border-radius: 10px;
+ background: var(--dashboard-link);
+ font-size: 13.5px;
+ font-weight: 600;
+ font-family: inherit;
+ color: #fff;
+ cursor: pointer;
+ transition: background 0.15s, border-color 0.15s;
+}
+@media (hover: hover) {
+ .share-modal-submit:hover:not(:disabled) {
+ background: var(--dashboard-link-hover);
+ border-color: var(--dashboard-link-hover);
+ }
+}
+.share-modal-submit:focus-visible {
+ outline: none;
+ box-shadow: 0 0 0 3px var(--select-ring);
+}
+.share-modal-submit:disabled {
+ background: var(--dashboard-input-background);
+ border-color: var(--dashboard-border);
+ color: var(--dashboard-text-muted);
+ cursor: default;
+}
+/* In flight it is disabled too, but should read as working, not empty */
+.share-modal-submit.share-modal-btn-busy:disabled {
+ background: var(--dashboard-link);
+ border-color: var(--dashboard-link);
+ color: #fff;
+ opacity: 0.75;
+}
+
+/* Spinner: hidden until its owner is loading/busy */
+.share-modal-spinner {
+ display: none;
+ width: 14px;
+ height: 14px;
+ border: 2px solid currentColor;
+ border-right-color: transparent;
+ border-radius: 50%;
+ animation: share-modal-spin 0.7s linear infinite;
+}
+@keyframes share-modal-spin {
+ to { transform: rotate(360deg); }
+}
+.share-modal-loading {
+ display: flex;
+ justify-content: center;
+ padding: 18px 0;
+ color: var(--dashboard-text-muted);
+}
+.share-modal-loading .share-modal-spinner {
+ display: block;
+ width: 18px;
+ height: 18px;
+}
+.share-modal-btn-busy .share-modal-spinner {
+ display: block;
+}
+
+/* Status line (aria-live) */
+.share-modal-status {
+ display: none;
+ margin-top: 12px;
+ padding: 9px 12px;
+ border: 1px solid transparent;
+ border-radius: 8px;
+ font-size: 13px;
+ line-height: 1.45;
+ word-break: break-word;
+}
+.share-modal-status-error {
+ display: block;
+ background: var(--dashboard-danger-background);
+ border-color: var(--dashboard-danger-border);
+ color: var(--dashboard-danger-text);
+}
+.share-modal-status-success {
+ display: block;
+ background: var(--dashboard-success-background);
+ border-color: var(--dashboard-success-border);
+ color: var(--dashboard-success-text);
+}
+
+/* Access list */
+.share-modal-heading {
+ margin: 18px 0 2px;
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--dashboard-text-secondary);
+}
+.share-modal-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 9px 0;
+ border-bottom: 1px solid var(--dashboard-border);
+}
+.share-modal-row:last-child {
+ border-bottom: none;
+}
+.share-modal-avatar {
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 30px;
+ height: 30px;
+ border-radius: 50%;
+ font-size: 13px;
+ font-weight: 600;
+ background: hsla(var(--share-avatar-hue, 213), 60%, 45%, 0.14);
+ color: hsl(var(--share-avatar-hue, 213), 45%, 38%);
+}
+.share-modal-row-who {
+ flex: 1 1 auto;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+}
+.share-modal-row-name {
+ font-size: 13.5px;
+ color: var(--dashboard-text-primary);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.share-modal-row-via {
+ font-size: 12px;
+ color: var(--dashboard-text-muted);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+/* Owner badge and the fixed mode of an inherited grant */
+.share-modal-row-tag {
+ flex-shrink: 0;
+ font-size: 12.5px;
+ color: var(--dashboard-text-secondary);
+}
+select.share-modal-row-mode {
+ flex-shrink: 0;
+ padding: 5px 24px 5px 8px;
+ border-color: transparent;
+ background-color: transparent;
+ font-size: 12.5px;
+ color: var(--dashboard-text-secondary);
+ background-position: right 6px center;
+}
+@media (hover: hover) {
+ select.share-modal-row-mode:hover:not(:disabled) {
+ background-color: var(--dashboard-hover);
+ color: var(--dashboard-text-primary);
+ }
+}
+select.share-modal-row-mode:focus {
+ padding: 5px 24px 5px 8px;
+ border: 1px solid var(--select-color);
+}
+.share-modal-revoke {
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 30px;
+ height: 30px;
+ padding: 0;
+ border: none;
+ border-radius: 8px;
+ background: transparent;
+ color: var(--dashboard-text-secondary);
+ cursor: pointer;
+ transition: background 0.15s, color 0.15s;
+}
+@media (hover: hover) {
+ .share-modal-revoke:hover:not(:disabled) {
+ background: var(--dashboard-danger-background);
+ color: var(--dashboard-danger-text);
+ }
+}
+.share-modal-revoke:focus-visible {
+ outline: none;
+ box-shadow: 0 0 0 3px var(--select-ring);
+}
+.share-modal-empty {
+ margin: 0;
+ padding: 10px 0;
+ font-size: 13px;
+ color: var(--dashboard-text-muted);
+}
+
+/* Inline revoke confirmation (in place of the row it removes) */
+.share-modal-row-confirm {
+ flex-wrap: wrap;
+}
+.share-modal-confirm-text {
+ flex: 1 1 180px;
+ min-width: 0;
+ font-size: 13px;
+ line-height: 1.45;
+ color: var(--dashboard-text-primary);
+ word-break: break-word;
+}
+.share-modal-confirm-actions {
+ flex-shrink: 0;
+ display: flex;
+ gap: 6px;
+ margin-left: auto;
+}
+.share-modal-btn-quiet,
+.share-modal-btn-danger {
+ padding: 6px 12px;
+ border-radius: 8px;
+ font-size: 12.5px;
+ font-weight: 600;
+ font-family: inherit;
+ cursor: pointer;
+ transition: background 0.15s, border-color 0.15s, color 0.15s;
+}
+.share-modal-btn-quiet {
+ border: 1px solid var(--dashboard-border);
+ background: var(--dashboard-card-background);
+ color: var(--dashboard-text-primary);
+}
+@media (hover: hover) {
+ .share-modal-btn-quiet:hover:not(:disabled) {
+ background: var(--dashboard-sidebar-background);
+ }
+}
+.share-modal-btn-danger {
+ border: 1px solid var(--dashboard-danger-border);
+ background: var(--dashboard-danger-background);
+ color: var(--dashboard-danger-text);
+}
+@media (hover: hover) {
+ .share-modal-btn-danger:hover:not(:disabled) {
+ border-color: var(--dashboard-danger-text);
+ }
+}
+.share-modal-btn-quiet:focus-visible,
+.share-modal-btn-danger:focus-visible {
+ outline: none;
+ box-shadow: 0 0 0 3px var(--select-ring);
+}
+.share-modal-btn-quiet:disabled,
+.share-modal-btn-danger:disabled {
+ opacity: 0.55;
+ cursor: default;
+}
+
+@media (prefers-color-scheme: dark) {
+ /* The avatar's tinted fill needs a lighter glyph over dark surfaces */
+ .share-modal-avatar {
+ background: hsla(var(--share-avatar-hue, 213), 55%, 55%, 0.2);
+ color: hsl(var(--share-avatar-hue, 213), 55%, 72%);
+ }
+}
+
+/* Mobile: dock to the bottom as a slide-up sheet */
+@media (max-width: 600px) {
+ .share-modal-overlay {
+ align-items: flex-end;
+ padding: 0;
+ }
+ .share-modal {
+ width: 100%;
+ max-width: none;
+ max-height: 88vh;
+ border-radius: 16px 16px 0 0;
+ border-bottom: none;
+ transform: translateY(24px);
+ }
+ .share-modal-overlay.share-modal-show .share-modal {
+ transform: translateY(0);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .share-modal-overlay,
+ .share-modal {
+ transition: none;
+ }
+ .share-modal-spinner {
+ animation-duration: 1.6s;
+ }
+}
+
/* A tile installed by a deep-link landing, not yet arrived: rendered in
place — layout, page count, and the wayfinding flip all see it — but held
invisible for the intro's arrival beat to reveal (see _spliceDeepLinkApp /
diff --git a/src/gui/src/helpers/generate_file_context_menu.js b/src/gui/src/helpers/generate_file_context_menu.js
index 19edbb889..1cb56dc15 100644
--- a/src/gui/src/helpers/generate_file_context_menu.js
+++ b/src/gui/src/helpers/generate_file_context_menu.js
@@ -44,6 +44,7 @@ import { can_rename, can_restructure, invalidate_shared_roots, shared_mode_for }
* @param {string} options.associated_app_name - Optional associated app
* @param {Function} options.onOpen - Optional custom open handler (used by Dashboard)
* @param {Function} options.onShowProperties - Optional custom properties handler (used by Dashboard); receives {name, path, uid, element}
+ * @param {Function} options.onShare - Optional custom share handler (used by Dashboard); receives {name, path, uid, element}
* @returns {Promise} Array of context menu items
*/
const generate_file_context_menu = async function (options) {
@@ -323,6 +324,18 @@ const generate_file_context_menu = async function (options) {
menu_items.push({
html: i18n('share_ellipsis'),
onClick: async function () {
+ // The Dashboard swaps in its own responsive modal via this hook;
+ // everywhere else falls back to the desktop share window.
+ if ( options.onShare ) {
+ options.onShare({
+ name: $(el_item).attr('data-name'),
+ path: $(el_item).attr('data-path'),
+ uid: $(el_item).attr('data-uid'),
+ element: el_item,
+ });
+ return;
+ }
+
UIWindowShare({
path: $(el_item).attr('data-path'),
name: $(el_item).attr('data-name'),
diff --git a/src/gui/src/i18n/translations/en.js b/src/gui/src/i18n/translations/en.js
index 017787add..89e8fc9b3 100644
--- a/src/gui/src/i18n/translations/en.js
+++ b/src/gui/src/i18n/translations/en.js
@@ -380,6 +380,7 @@ const en = {
share_access_read: 'Can view',
share_access_write: 'Can edit',
share_access_manage: 'Can edit & share',
+ share_access_level: 'Access level',
share_add_people: 'Add people by email or username',
share_who_has_access: 'Who has access',
share_no_one: 'Not shared with anyone yet.',