mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-24 15:07:17 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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 "&".
|
||||
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 };
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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.',
|
||||
|
||||
Reference in New Issue
Block a user