feat(dashboard): rewrite Files-tab sharing as a from-scratch modal

The Files tab's Share… action opened UIWindowShare, a desktop UIWindow,
which doesn't fit the dashboard. Replace it with UIShareModal, built on
the same overlay pattern as UIItemPropertiesModal: a centered card on
desktop, a bottom sheet on mobile, styled with the dashboard's design
tokens (dark mode included).

Wiring follows the onShowProperties precedent: generate_file_context_menu
gains an optional onShare hook that TabFiles supplies; every other caller
falls back to the desktop share window unchanged.

Feature parity with UIWindowShare — grant by email/username with an
access level, list who has access (owner, direct, and inherited-from-
ancestor grants), change a grant's mode, revoke — plus:

- revoke confirmation inlined into the row instead of a stacked UIAlert
- real form semantics: Enter submits, button disabled until input,
  spinner while in flight, API errors keep the typed name for correction
- dialog a11y: focus moves in on open and back on close, Tab is trapped,
  Escape closes (first Escape cancels an open revoke confirmation),
  status messages announce via aria-live
- backdrop close keyed on where the press started, so a text-selection
  drag out of the input can't dismiss the dialog
- name-derived avatar colors and a loading state for the first fetch

Also fixes a double-encoding bug (shared by UIWindowShare) where mode
labels ran through html_encode twice, rendering "Can edit & share".
This commit is contained in:
Nariman Jelveh
2026-08-17 21:22:14 -07:00
parent cabedc30b0
commit 0bf6622c4c
6 changed files with 905 additions and 3 deletions
+11
View File
@@ -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;
+377
View File
@@ -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 <https://www.gnu.org/licenses/>.
*/
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 = `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`;
// 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 "&amp;".
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) =>
`<option value="${html_encode(mode)}"${mode === current ? ' selected' : ''}>${html_encode(mode_label(mode))}</option>`,
)
.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 `<span class="share-modal-avatar" style="--share-avatar-hue: ${hue_for(name || '')}" aria-hidden="true">${html_encode(initial)}</span>`;
};
/**
* 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 <body>)
* @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 = $(`
<div class="share-modal-overlay">
<div class="share-modal" role="dialog" aria-modal="true" aria-label="${html_encode(item_name)} — ${i18n('share')}">
<div class="share-modal-header">
<div class="share-modal-title">
<span class="share-modal-title-icon"></span>
<div class="share-modal-title-text">
<span class="share-modal-title-name enable-user-select">${html_encode(item_name)}</span>
<span class="share-modal-title-sub">${i18n('share')}</span>
</div>
</div>
<button type="button" class="share-modal-close" aria-label="${i18n('close')}" title="${i18n('close')}">${closeIcon}</button>
</div>
<div class="share-modal-body">
<form class="share-modal-add" novalidate>
<div class="share-modal-add-row">
<input type="text" class="share-modal-recipient" autocomplete="off" autocapitalize="off" spellcheck="false" enterkeyhint="send"
placeholder="${i18n('share_add_people')}" aria-label="${i18n('share_add_people')}" />
<select class="share-modal-mode" aria-label="${i18n('share_access_level')}">${options_for('read')}</select>
</div>
<button type="submit" class="share-modal-submit" disabled>
<span class="share-modal-spinner" aria-hidden="true"></span>
<span class="share-modal-submit-label">${i18n('share')}</span>
</button>
</form>
<div class="share-modal-status" role="status" aria-live="polite"></div>
<h3 class="share-modal-heading">${i18n('share_who_has_access')}</h3>
<div class="share-modal-list" aria-busy="true">
<div class="share-modal-loading"><span class="share-modal-spinner" aria-hidden="true"></span></div>
</div>
</div>
</div>
</div>
`);
$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 += '<div class="share-modal-row">';
rows += avatar_html(item_owner);
rows += `<span class="share-modal-row-who"><span class="share-modal-row-name enable-user-select">${html_encode(item_owner)}${item_owner === window.user.username ? ` (${i18n('share_you')})` : ''}</span></span>`;
rows += `<span class="share-modal-row-tag">${i18n('share_owner')}</span>`;
rows += '</div>';
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 += '<div class="share-modal-row share-modal-row-inherited">';
rows += avatar_html(share.holder ?? '');
rows += `<span class="share-modal-row-who"><span class="share-modal-row-name enable-user-select">${holder}${you_suffix}</span><span class="share-modal-row-via">${i18n('share_inherited_via', { folder: path.basename(share.inheritedFrom) })}</span></span>`;
rows += `<span class="share-modal-row-tag">${html_encode(mode_label(share.mode))}</span>`;
rows += '</div>';
continue;
}
rows += '<div class="share-modal-row">';
rows += avatar_html(share.holder ?? '');
rows += `<span class="share-modal-row-who"><span class="share-modal-row-name enable-user-select">${holder}${you_suffix}</span></span>`;
rows += `<select class="share-modal-row-mode" data-holder="${holder}" aria-label="${i18n('share_access_level')}">${options_for(share.mode)}</select>`;
rows += `<button type="button" class="share-modal-revoke" data-holder="${holder}" title="${i18n('share_remove_access')}" aria-label="${i18n('share_remove_access')}">${icons.trash}</button>`;
rows += '</div>';
}
if ( !shares.length ) {
rows += `<p class="share-modal-empty">${i18n('share_no_one')}</p>`;
}
$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(`<img src="${html_encode(icon.image)}" alt="">`);
}
} 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(`
<div class="share-modal-row share-modal-row-confirm" data-holder="${enc}">
<span class="share-modal-confirm-text">${i18n('share_confirm_remove', { recipient: holder })}</span>
<span class="share-modal-confirm-actions">
<button type="button" class="share-modal-btn-quiet share-modal-confirm-cancel">${i18n('cancel')}</button>
<button type="button" class="share-modal-btn-danger share-modal-confirm-remove" data-holder="${enc}">${i18n('share_remove')}</button>
</span>
</div>
`);
$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 };
}
+5 -3
View File
@@ -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 "&amp;".
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;
};
+498
View File
@@ -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 /
@@ -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>} 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'),
+1
View File
@@ -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.',