From 0bf6622c4cea67aa97d3a6430ec317866989d477 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Mon, 17 Aug 2026 21:22:14 -0700 Subject: [PATCH] feat(dashboard): rewrite Files-tab sharing as a from-scratch modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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". --- src/gui/src/UI/Dashboard/TabFiles.js | 11 + src/gui/src/UI/Dashboard/UIShareModal.js | 377 +++++++++++++ src/gui/src/UI/UIWindowShare.js | 8 +- src/gui/src/css/dashboard.css | 498 ++++++++++++++++++ .../src/helpers/generate_file_context_menu.js | 13 + src/gui/src/i18n/translations/en.js | 1 + 6 files changed, 905 insertions(+), 3 deletions(-) create mode 100644 src/gui/src/UI/Dashboard/UIShareModal.js 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) => + ``, + ) + .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 ``; +}; + +/** + * 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 = $(` + + `); + + $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 += ''; + + 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 += ''; + continue; + } + rows += ''; + } + if ( !shares.length ) { + rows += ``; + } + $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(` + + `); + $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.',