From e4bb5b064a2dd0c65c11458e6afa014840e0d446 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Fri, 5 Jun 2026 15:01:22 +0200 Subject: [PATCH] feat(tooltip): add tooltip on users and groups --- static/css/components/tooltip.css | 95 ++++++++++ static/css/components/userVignette.css | 6 + static/css/main.css | 1 + static/js/components/groupVignette.js | 148 +++++++++++++++- static/js/components/mySharesList.js | 6 +- static/js/components/userVignette.js | 24 ++- static/js/utils/tooltip.js | 232 +++++++++++++++++++++++++ static/locales/ar.json | 5 +- static/locales/de.json | 5 +- static/locales/en.json | 5 +- static/locales/es.json | 5 +- static/locales/fa.json | 5 +- static/locales/fr.json | 5 +- static/locales/hi.json | 5 +- static/locales/it.json | 5 +- static/locales/ja.json | 5 +- static/locales/ko.json | 5 +- static/locales/nl.json | 5 +- static/locales/pl.json | 5 +- static/locales/pt.json | 5 +- static/locales/ru.json | 5 +- static/locales/zh-TW.json | 5 +- static/locales/zh.json | 5 +- 23 files changed, 566 insertions(+), 26 deletions(-) create mode 100644 static/css/components/tooltip.css create mode 100644 static/js/utils/tooltip.js diff --git a/static/css/components/tooltip.css b/static/css/components/tooltip.css new file mode 100644 index 00000000..9c0aada3 --- /dev/null +++ b/static/css/components/tooltip.css @@ -0,0 +1,95 @@ +/* ── Tooltip primitive ───────────────────────────────────────────── + * + * Generic on-hover tooltip used app-wide. Two flavours: + * + * 1. Single-line label, content from `data-tooltip="…"` on the trigger. + * Use `attachTooltip(el, text)` from `static/js/utils/tooltip.js`. + * 2. Rich popover with a DOM subtree, populated lazily on first hover. + * Use `attachRichTooltip(el, populateAsync)` for things like the + * group-vignette member list. + * + * Both are portal'd to `document.body` (the JS helper creates a child + * of body and positions it with `position: fixed`) so they escape the + * `overflow: hidden` clipping that lives on lane wrappers, list rows, + * and any other "contain my children" ancestor. Without the portal, + * tooltips near the edges of those containers get cropped — exactly + * the bug that prompted this refactor. + * + * Hover-intent timing is asymmetric on purpose: + * - Entry: 250 ms delay before the fade-in starts. Short enough to + * feel responsive (much faster than the browser-native `title` + * delay, which is ~500–1500 ms), long enough to suppress flicker + * on accidental mouseovers. + * - Exit: 0 ms delay. The tooltip dismisses immediately when the + * user moves away. + * + * The class toggle is JS-driven (mouseenter/leave + focusin/out + * listeners on the trigger); the visible transition lives entirely + * in CSS so a setTimeout never gates the visual change. + */ + +.oxi-tooltip-popover { + position: fixed; + min-width: 0; + max-width: 280px; + padding: 6px 10px; + background-color: var(--color-text); + color: var(--color-bg-page); + font-size: 12px; + line-height: 1.5; + text-align: left; + border-radius: 6px; + box-shadow: 0 2px 8px var(--color-shadow); + pointer-events: none; + opacity: 0; + /* Default (no `--visible`): fast hide, no delay. */ + transition: opacity 100ms ease-out 0ms; + z-index: 10000; +} + +/* Simple (data-tooltip) flavour — keeps the label on one line so it + reads as a short caption, not a paragraph. Used by short labels + like a user vignette's email-on-hover. */ +.oxi-tooltip-popover--simple { + white-space: nowrap; + max-width: none; +} + +/* Visible state overrides the transition to add the hover-intent + delay on the *entry* path. Removing the class falls back to + the default rule above (no delay → immediate fade-out). */ +.oxi-tooltip-popover--visible { + opacity: 1; + transition: opacity 100ms ease-out 250ms; +} + +/* ── Rich-popover layout helpers ────────────────────────────────── */ + +.oxi-tooltip-popover__line { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* "+N" overflow badge — small pill at the end of a list of items when + there are more than the tooltip cares to show. Lighter background + so the count reads as meta-information, not just another line. */ +.oxi-tooltip-popover__overflow { + display: inline-block; + margin-top: 4px; + padding: 1px 7px; + font-size: 11px; + font-weight: 600; + color: var(--color-bg-page); + background-color: var(--color-text-faint); + border-radius: 9999px; +} + +/* Placeholder line shown briefly between the first hover and the + populate() callback's resolve (e.g. while `/api/groups/{id}/members` + is in flight). Italic + dimmed so the user perceives it as a + transitional state, not real content. */ +.oxi-tooltip-popover__placeholder { + color: var(--color-text-faint); + font-style: italic; +} diff --git a/static/css/components/userVignette.css b/static/css/components/userVignette.css index b4f52f5f..d4b75978 100644 --- a/static/css/components/userVignette.css +++ b/static/css/components/userVignette.css @@ -21,6 +21,12 @@ overflow: hidden; } +/* The on-hover email tooltip (used by `createUserVignette` when the + email is set) and the rich group-members popover both live in + `components/tooltip.css` and are attached at runtime via + `static/js/utils/tooltip.js` — kept generic so other surfaces + (role chips, link chips, action buttons) can opt in the same way. */ + .user-vignette__avatar { border-radius: 50%; display: flex; diff --git a/static/css/main.css b/static/css/main.css index 660aa9b7..b1ba10cd 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -23,6 +23,7 @@ @import url("./components/shareDialog.css"); @import url("./components/shareModal.css"); @import url("./components/groupsModal.css"); +@import url("./components/tooltip.css"); @import url("./components/userVignette.css"); @import url("./components/linkChip.css"); @import url("./components/uploadDropdown.css"); diff --git a/static/js/components/groupVignette.js b/static/js/components/groupVignette.js index b395eb56..7e1fa671 100644 --- a/static/js/components/groupVignette.js +++ b/static/js/components/groupVignette.js @@ -17,6 +17,142 @@ */ import { escapeHtml } from '../core/formatters.js'; +import { i18n } from '../core/i18n.js'; +import { groups, INTERNAL_GROUP_ID } from '../model/groups.js'; +import { systemUsers } from '../model/systemUsers.js'; +import { attachRichTooltip, OxiTooltipClass } from '../utils/tooltip.js'; + +/** Max member names displayed in the on-hover tooltip; any extra count + * surfaces as a "+N" badge on the last line. Kept small enough to fit + * inside the 280 px tooltip without scrolling, large enough to be + * informative for the typical share-with-a-team case. */ +const MAX_MEMBERS_IN_TOOLTIP = 8; + +/** + * Session-scoped cache of resolved member lists, keyed by group UUID. + * One entry per group ever hovered; the promise is reused across + * subsequent vignettes for the same group so a list of 50 rows that + * all reference the same group hits `/api/groups/{id}/members` once. + * + * @type {Map>} + */ +const _membersCache = new Map(); + +/** + * Resolve the first N direct members of a group to display names. + * Idempotent + memoised across vignettes that share a group id. + * + * Failures (403 on a group the caller can't list, 404 on a stale id, + * network errors) resolve to an empty-names + zero-total shape so the + * UI shows a graceful "no members" placeholder instead of throwing. + * + * @param {string} groupId + * @returns {Promise<{ names: string[], total: number }>} + */ +async function _resolveGroupMembers(groupId) { + const cached = _membersCache.get(groupId); + if (cached) return cached; + + const promise = (async () => { + /** @type {import('../core/types.js').GroupMemberItem[]} */ + let members; + try { + members = await groups.listMembers(groupId); + } catch { + return { names: [], total: 0 }; + } + const total = members.length; + // Only resolve the names we'll actually display — the "+N more" + // badge counts the rest from `total - MAX`. Cuts down on the + // number of /api/users/{id} backfills for big groups. + const slice = members.slice(0, MAX_MEMBERS_IN_TOOLTIP); + + // Parallelise the per-member name lookups so a 8-member group + // resolves in one round-trip's worth of latency, not eight. + const names = await Promise.all( + slice.map(async (m) => { + if (m.kind === 'user') { + try { + return await systemUsers.getDisplayName(m.id); + } catch { + return `${m.id.slice(0, 8)}…`; + } + } + // Nested group — resolve via groups model. + try { + const resolved = await groups.resolveGroups([m.id]); + const g = resolved?.[m.id]; + if (g?.name) return `👥 ${g.name}`; + } catch { + // fall through + } + return `👥 ${m.id.slice(0, 8)}…`; + }) + ); + return { names, total }; + })(); + + _membersCache.set(groupId, promise); + return promise; +} + +/** + * Attach the on-hover member popover to a group vignette. Delegates + * positioning + show/hide + portal placement to the generic + * `attachRichTooltip` helper (see `utils/tooltip.js`); this function + * just owns the per-row content — the placeholder, the member lines, + * and the "+N" overflow badge. + * + * @param {HTMLElement} vignetteEl the wrapper returned by `createGroupVignette` + * @param {string} groupId UUID of the group whose members to show + */ +function _attachMembersTooltip(vignetteEl, groupId) { + attachRichTooltip(vignetteEl, async (pop) => { + // Placeholder shown until the network resolves. Slow connections + // see "Loading members…" instead of an empty box. + const placeholder = document.createElement('div'); + placeholder.className = OxiTooltipClass.PLACEHOLDER; + placeholder.textContent = i18n.t('groups.members_loading', 'Loading members…'); + pop.appendChild(placeholder); + + const { names, total } = await _resolveGroupMembers(groupId); + pop.replaceChildren(); + + if (total === 0) { + const empty = document.createElement('div'); + empty.className = OxiTooltipClass.PLACEHOLDER; + // Special case: the built-in "Internal" virtual group has + // implicit membership — it represents every internal user + // on this server. `listMembers` returns an empty array + // because no explicit rows exist in `auth.subject_group_members`, + // but "No members" would mislead. Surface the real meaning + // instead. Future virtual groups with implicit membership + // (e.g. "Everyone") would extend this branch. + empty.textContent = + groupId === INTERNAL_GROUP_ID + ? i18n.t('groups.virtual_internal_explanation', 'Every internal user on this server') + : i18n.t('groups.members_empty', 'No members'); + pop.appendChild(empty); + return; + } + for (const name of names) { + const line = document.createElement('div'); + line.className = OxiTooltipClass.LINE; + line.textContent = name; + pop.appendChild(line); + } + if (total > MAX_MEMBERS_IN_TOOLTIP) { + const overflow = document.createElement('div'); + overflow.className = OxiTooltipClass.LINE; + // Inner badge so the "+N" reads as a count, not another name. + const badge = document.createElement('span'); + badge.className = OxiTooltipClass.OVERFLOW; + badge.textContent = `+${total - MAX_MEMBERS_IN_TOOLTIP}`; + overflow.append('… ', badge); + pop.appendChild(overflow); + } + }); +} /** * Build the inline vignette. @@ -26,17 +162,25 @@ import { escapeHtml } from '../core/formatters.js'; * @param {'xs'|'sm'|'md'|'list'} [size='sm'] * Matches the size scale of `createUserVignette`. The size class is * `user-vignette--${size}`; see `static/css/components/userVignette.css`. - * @param {{ icon?: string }} [opts] + * @param {{ icon?: string, groupId?: string }} [opts] * `icon`: FA class string without the `fa-` prefix (defaults to * `'fa-user-group'`). Used to signal virtual groups visually — see * `groupIconClass()` / `groupIconClassByVirtual()` in `./groupDisplay.js` * return a distinct icon for system-wide virtual groups (Internal, * future Everyone, …). + * + * `groupId`: UUID of the group. When supplied, hovering the vignette + * reveals a tooltip listing up to {@link MAX_MEMBERS_IN_TOOLTIP} + * member names with a `+N` badge for overflow. The member list is + * fetched lazily on first hover, memoised across all vignettes that + * share the same id, so a row of 50 grants pointing at the same + * team only hits `/api/groups/{id}/members` once. * @returns {HTMLElement} */ -export function createGroupVignette(name, size = 'sm', { icon = 'fa-user-group' } = {}) { +export function createGroupVignette(name, size = 'sm', { icon = 'fa-user-group', groupId } = {}) { const el = document.createElement('div'); el.className = `user-vignette user-vignette-group user-vignette--${size}`; el.innerHTML = `${escapeHtml(name)}`; + if (groupId) _attachMembersTooltip(el, groupId); return el; } diff --git a/static/js/components/mySharesList.js b/static/js/components/mySharesList.js index e079837f..6c374406 100644 --- a/static/js/components/mySharesList.js +++ b/static/js/components/mySharesList.js @@ -270,7 +270,8 @@ class MySharesList { } if (swimKey.startsWith('group:')) { return createGroupVignette(this._groupName(grant.subject_id), 'list', { - icon: this._groupIcon(grant.subject_id) + icon: this._groupIcon(grant.subject_id), + groupId: grant.subject_id }); } const el = document.createElement('div'); @@ -347,7 +348,8 @@ class MySharesList { } else if (grant.subject_type === 'group') { el.appendChild( createGroupVignette(this._groupName(grant.subject_id), 'xs', { - icon: this._groupIcon(grant.subject_id) + icon: this._groupIcon(grant.subject_id), + groupId: grant.subject_id }) ); } else { diff --git a/static/js/components/userVignette.js b/static/js/components/userVignette.js index a760cc9a..ba1438fc 100644 --- a/static/js/components/userVignette.js +++ b/static/js/components/userVignette.js @@ -20,6 +20,7 @@ */ import { systemUsers } from '../model/systemUsers.js'; +import { attachTooltip } from '../utils/tooltip.js'; // ── Helpers ──────────────────────────────────────────────────────────────────── @@ -167,12 +168,23 @@ export function createUserVignette(userId, size = 'sm', { showName = true, showE ]).then(([name, photo, email, isExternal]) => { if (nameEl) nameEl.textContent = name; if (emailEl) emailEl.textContent = email ?? ''; - // Tooltip: surface the email on hover when it's not already - // rendered as the visible label (showEmail mode) and isn't - // already the displayed name (the fallback case where the user - // has no given/family/username and the label IS the email). - if (email && !showEmail && email !== name) { - wrapper.title = email; + // Tooltip: surface the email on hover for every vignette that + // has one — including external users whose visible label IS + // the email already. The redundant "alice@x.com → alice@x.com" + // hover is a small price for keeping the interaction uniform: + // every user row in a list reacts to hover the same way, so + // the user doesn't learn "internal rows have tooltips, external + // rows are silent". Suppressed only in `showEmail` mode, where + // the email is already a permanent line below the name. + // + // `attachTooltip` portals the popover to `document.body` and + // applies the shared 250 ms hover-intent delay (much faster + // than the native `title` attribute's ~500–1500 ms wait). + // `aria-label` is set in parallel so screen readers still get + // the email — popover content is mouse/keyboard-hover only. + if (email && !showEmail) { + wrapper.setAttribute('aria-label', email); + attachTooltip(wrapper, email); } if (photo) { _applyPhoto(avatar, photo, name); diff --git a/static/js/utils/tooltip.js b/static/js/utils/tooltip.js new file mode 100644 index 00000000..dc16bc78 --- /dev/null +++ b/static/js/utils/tooltip.js @@ -0,0 +1,232 @@ +// @ts-check + +/** + * Generic tooltip helper used app-wide. + * + * Two flavours: + * + * - {@link attachTooltip} — short text label, optionally populated + * from a `data-tooltip` attribute. Use + * for one-liners (the email-on-hover on + * a user vignette, the title-text on a + * chip / button, etc.). + * - {@link attachRichTooltip} — structured DOM populated lazily on + * first hover. Use when the content is + * multi-line, async-fetched, or needs + * inner styling (e.g. the member list + * in a group vignette). + * + * Both portal the popover to `document.body` and position it with + * `position: fixed` so it escapes every ancestor `overflow: hidden` + * clip in the page. This is the only reliable cross-browser way to + * keep tooltips fully visible from triggers buried inside list rows, + * scroll containers, or modal panels. + * + * The class toggle (`oxi-tooltip-popover--visible`) is JS-driven on + * `mouseenter` / `mouseleave` / `focusin` / `focusout`. The hover-intent + * delay (250 ms before fade-in, 0 ms before fade-out) lives entirely + * in the CSS transition rules — never in a JS `setTimeout`. See + * `static/css/components/tooltip.css` for the timing source of truth. + * + * Layout helpers exported for the rich variant: + * - `OxiTooltipClass.LINE` — apply to each row inside the popover + * - `OxiTooltipClass.OVERFLOW` — small "+N" badge for truncated lists + * - `OxiTooltipClass.PLACEHOLDER` — italic dimmed text for loading / + * empty states + */ + +const POPOVER_CLASS = 'oxi-tooltip-popover'; +const VISIBLE_CLASS = 'oxi-tooltip-popover--visible'; +const SIMPLE_CLASS = 'oxi-tooltip-popover--simple'; + +/** Class names exported so callers can build the popover body with the + * layout helpers without dragging in private CSS module conventions. */ +export const OxiTooltipClass = Object.freeze({ + LINE: 'oxi-tooltip-popover__line', + OVERFLOW: 'oxi-tooltip-popover__overflow', + PLACEHOLDER: 'oxi-tooltip-popover__placeholder' +}); + +/** Distance between the tooltip and the trigger edge, in pixels. */ +const GAP = 6; + +/** Inset from the viewport edges when clamping the tooltip position. */ +const MARGIN = 8; + +/** + * Position `popover` above (or below, when there isn't room above) the + * given trigger element. Uses `position: fixed` so it escapes any + * ancestor `overflow: hidden`. Clamps horizontally and vertically into + * the viewport so tooltips near the edges still read cleanly. + * + * @param {HTMLElement} popover + * @param {HTMLElement} triggerEl + */ +function _positionPopover(popover, triggerEl) { + const triggerRect = triggerEl.getBoundingClientRect(); + // Measure after content has been added so we know the final size. + const popRect = popover.getBoundingClientRect(); + const vw = window.innerWidth; + const vh = window.innerHeight; + + // Vertical: prefer above the trigger. Flip below when there's not + // enough room above. + let top = triggerRect.top - popRect.height - GAP; + if (top < MARGIN) { + top = triggerRect.bottom + GAP; + } + + // Horizontal: center on the trigger, clamp into the viewport. + let left = triggerRect.left + triggerRect.width / 2 - popRect.width / 2; + if (left < MARGIN) left = MARGIN; + if (left + popRect.width > vw - MARGIN) left = vw - popRect.width - MARGIN; + + // Final vertical clamp — covers the (very rare) case where the + // tooltip is taller than the visible viewport. + if (top + popRect.height > vh - MARGIN) top = vh - popRect.height - MARGIN; + if (top < MARGIN) top = MARGIN; + + popover.style.top = `${top}px`; + popover.style.left = `${left}px`; +} + +/** + * Internal: wire mouseenter/leave + focusin/out listeners on `triggerEl`, + * lazily create the popover element on first hover, and call `populate` + * once to fill it. Returns a cleanup function that removes the + * listeners and the popover element. + * + * @param {HTMLElement} triggerEl + * @param {(popover: HTMLElement) => void | Promise} populate + * Called exactly once when the popover is first shown. Synchronous + * populates take effect immediately; async populates show the + * placeholder span (if you created one) until the promise resolves, + * after which the popover is re-positioned to account for size + * changes. + * @param {{ simple?: boolean }} [opts] + * `simple`: add the `--simple` modifier so the popover uses the + * single-line label style (white-space: nowrap, no min-width). + * @returns {() => void} Cleanup; idempotent. + */ +function _attach(triggerEl, populate, opts = {}) { + /** @type {HTMLElement | null} */ + let popover = null; + let populated = false; + let detached = false; + + const ensurePopover = () => { + if (popover) return popover; + popover = document.createElement('div'); + popover.className = POPOVER_CLASS + (opts.simple ? ` ${SIMPLE_CLASS}` : ''); + // ARIA: behave like a tooltip for screen readers — though we + // also rely on `aria-label` / surrounding text since hover + // isn't reachable via keyboard-only assistive tech. + popover.setAttribute('role', 'tooltip'); + document.body.appendChild(popover); + return popover; + }; + + const show = () => { + if (detached) return; + const pop = ensurePopover(); + + if (!populated) { + populated = true; + // Synchronous populate paths render immediately. Async + // populates (those returning a Promise) re-position after + // resolve so the tooltip catches up to its final size — + // important when the placeholder text is much narrower + // than the eventual content. + const result = populate(pop); + if (result && typeof (/** @type {Promise} */ (result).then) === 'function') { + /** @type {Promise} */ (result).then(() => { + if (popover?.classList.contains(VISIBLE_CLASS)) { + _positionPopover(popover, triggerEl); + } + }); + } + } + + _positionPopover(pop, triggerEl); + pop.classList.add(VISIBLE_CLASS); + }; + + const hide = () => { + if (popover) popover.classList.remove(VISIBLE_CLASS); + }; + + triggerEl.addEventListener('mouseenter', show); + triggerEl.addEventListener('mouseleave', hide); + triggerEl.addEventListener('focusin', show); + triggerEl.addEventListener('focusout', hide); + + return () => { + if (detached) return; + detached = true; + triggerEl.removeEventListener('mouseenter', show); + triggerEl.removeEventListener('mouseleave', hide); + triggerEl.removeEventListener('focusin', show); + triggerEl.removeEventListener('focusout', hide); + popover?.remove(); + popover = null; + }; +} + +/** + * Attach a simple single-line tooltip to `triggerEl`. + * + * @param {HTMLElement} triggerEl + * @param {string} text The label to display. + * @returns {() => void} Cleanup function; idempotent. + * + * @example + * attachTooltip(emailBadgeEl, 'alice@example.com'); + */ +export function attachTooltip(triggerEl, text) { + return _attach( + triggerEl, + (pop) => { + pop.textContent = text; + }, + { simple: true } + ); +} + +/** + * Attach a rich tooltip with structured DOM populated lazily on first + * hover. The `populate` callback receives the popover element and can + * append whatever children it wants. Return a Promise to populate + * async — the popover re-positions on resolve. + * + * @param {HTMLElement} triggerEl + * @param {(popover: HTMLElement) => void | Promise} populate + * @returns {() => void} Cleanup function; idempotent. + * + * @example + * attachRichTooltip(groupEl, async (pop) => { + * const placeholder = document.createElement('div'); + * placeholder.className = OxiTooltipClass.PLACEHOLDER; + * placeholder.textContent = i18n.t('groups.members_loading'); + * pop.appendChild(placeholder); + * const members = await fetchMembers(groupId); + * pop.replaceChildren(); // drop the placeholder + * for (const name of members.slice(0, 8)) { + * const line = document.createElement('div'); + * line.className = OxiTooltipClass.LINE; + * line.textContent = name; + * pop.appendChild(line); + * } + * if (members.length > 8) { + * const overflow = document.createElement('div'); + * overflow.className = OxiTooltipClass.LINE; + * const badge = document.createElement('span'); + * badge.className = OxiTooltipClass.OVERFLOW; + * badge.textContent = `+${members.length - 8}`; + * overflow.append('… ', badge); + * pop.appendChild(overflow); + * } + * }); + */ +export function attachRichTooltip(triggerEl, populate) { + return _attach(triggerEl, populate); +} diff --git a/static/locales/ar.json b/static/locales/ar.json index fa1cb9c3..559f0a8b 100644 --- a/static/locales/ar.json +++ b/static/locales/ar.json @@ -867,7 +867,10 @@ "member_count_other": "{count} أعضاء", "delete_confirm_label": "اكتب اسم المجموعة للتأكيد:", "delete_confirm_mismatch": "اكتب اسم المجموعة كما هو للتأكيد.", - "virtual_internal_name": "داخلي" + "virtual_internal_name": "داخلي", + "members_loading": "جارٍ تحميل الأعضاء…", + "members_empty": "لا يوجد أعضاء", + "virtual_internal_explanation": "كل مستخدم داخلي على هذا الخادم" }, "myshares": { "copyLink": "نسخ الرابط", diff --git a/static/locales/de.json b/static/locales/de.json index 984cadf2..ee333f84 100644 --- a/static/locales/de.json +++ b/static/locales/de.json @@ -867,7 +867,10 @@ "member_count_other": "{count} Mitglieder", "delete_confirm_label": "Tippe den Gruppennamen zur Bestätigung ein:", "delete_confirm_mismatch": "Tippe den Gruppennamen exakt zur Bestätigung ein.", - "virtual_internal_name": "Intern" + "virtual_internal_name": "Intern", + "members_loading": "Mitglieder werden geladen…", + "members_empty": "Keine Mitglieder", + "virtual_internal_explanation": "Jeder interne Benutzer auf diesem Server" }, "myshares": { "copyLink": "Link kopieren", diff --git a/static/locales/en.json b/static/locales/en.json index a606b0fa..7e0fa4b7 100644 --- a/static/locales/en.json +++ b/static/locales/en.json @@ -862,6 +862,8 @@ "name_placeholder": "engineering", "description_label": "Description (optional)", "members_section": "Members", + "members_loading": "Loading members…", + "members_empty": "No members", "add_member_placeholder": "Add a user or group…", "no_members": "No members yet.", "remove_member": "Remove", @@ -877,6 +879,7 @@ "member_count_other": "{count} members", "delete_confirm_label": "Type the group name to confirm:", "delete_confirm_mismatch": "Type the group name exactly to confirm.", - "virtual_internal_name": "Internal" + "virtual_internal_name": "Internal", + "virtual_internal_explanation": "Every internal user on this server" } } diff --git a/static/locales/es.json b/static/locales/es.json index 01657b03..d4baccf5 100644 --- a/static/locales/es.json +++ b/static/locales/es.json @@ -867,7 +867,10 @@ "member_count_other": "{count} miembros", "delete_confirm_label": "Escribe el nombre del grupo para confirmar:", "delete_confirm_mismatch": "Escribe el nombre del grupo exactamente para confirmar.", - "virtual_internal_name": "Interno" + "virtual_internal_name": "Interno", + "members_loading": "Cargando miembros…", + "members_empty": "Sin miembros", + "virtual_internal_explanation": "Todos los usuarios internos de este servidor" }, "myshares": { "copyLink": "Copiar enlace", diff --git a/static/locales/fa.json b/static/locales/fa.json index 61ab2a56..58b745c1 100644 --- a/static/locales/fa.json +++ b/static/locales/fa.json @@ -867,7 +867,10 @@ "member_count_other": "{count} عضو", "delete_confirm_label": "نام گروه را برای تأیید وارد کنید:", "delete_confirm_mismatch": "نام گروه را دقیقاً برای تأیید وارد کنید.", - "virtual_internal_name": "داخلی" + "virtual_internal_name": "داخلی", + "members_loading": "در حال بارگیری اعضا…", + "members_empty": "بدون عضو", + "virtual_internal_explanation": "هر کاربر داخلی روی این سرور" }, "myshares": { "copyLink": "کپی پیوند", diff --git a/static/locales/fr.json b/static/locales/fr.json index 51c4afa5..e161f541 100644 --- a/static/locales/fr.json +++ b/static/locales/fr.json @@ -867,7 +867,10 @@ "member_count_other": "{count} membres", "delete_confirm_label": "Tapez le nom du groupe pour confirmer :", "delete_confirm_mismatch": "Tapez le nom du groupe exactement pour confirmer.", - "virtual_internal_name": "Interne" + "virtual_internal_name": "Interne", + "members_loading": "Chargement des membres…", + "members_empty": "Aucun membre", + "virtual_internal_explanation": "Tous les utilisateurs internes de ce serveur" }, "myshares": { "copyLink": "Copier le lien", diff --git a/static/locales/hi.json b/static/locales/hi.json index 62b4dda1..e451ecdb 100644 --- a/static/locales/hi.json +++ b/static/locales/hi.json @@ -867,7 +867,10 @@ "member_count_other": "{count} सदस्य", "delete_confirm_label": "पुष्टि के लिए समूह का नाम लिखें:", "delete_confirm_mismatch": "पुष्टि के लिए समूह का नाम बिल्कुल वैसा ही लिखें।", - "virtual_internal_name": "आंतरिक" + "virtual_internal_name": "आंतरिक", + "members_loading": "सदस्य लोड हो रहे हैं…", + "members_empty": "कोई सदस्य नहीं", + "virtual_internal_explanation": "इस सर्वर पर हर आंतरिक उपयोगकर्ता" }, "myshares": { "copyLink": "लिंक कॉपी करें", diff --git a/static/locales/it.json b/static/locales/it.json index 0d3860d2..e16e50b1 100644 --- a/static/locales/it.json +++ b/static/locales/it.json @@ -867,7 +867,10 @@ "member_count_other": "{count} membri", "delete_confirm_label": "Digita il nome del gruppo per confermare:", "delete_confirm_mismatch": "Digita esattamente il nome del gruppo per confermare.", - "virtual_internal_name": "Interno" + "virtual_internal_name": "Interno", + "members_loading": "Caricamento membri…", + "members_empty": "Nessun membro", + "virtual_internal_explanation": "Ogni utente interno su questo server" }, "myshares": { "copyLink": "Copia link", diff --git a/static/locales/ja.json b/static/locales/ja.json index 84debb3f..25beab40 100644 --- a/static/locales/ja.json +++ b/static/locales/ja.json @@ -867,7 +867,10 @@ "member_count_other": "{count} メンバー", "delete_confirm_label": "確認のためにグループ名を入力してください:", "delete_confirm_mismatch": "確認のためにグループ名を正確に入力してください。", - "virtual_internal_name": "内部" + "virtual_internal_name": "内部", + "members_loading": "メンバーを読み込み中…", + "members_empty": "メンバーなし", + "virtual_internal_explanation": "このサーバー上のすべての内部ユーザー" }, "myshares": { "copyLink": "リンクをコピー", diff --git a/static/locales/ko.json b/static/locales/ko.json index 345cbe13..e93ba416 100644 --- a/static/locales/ko.json +++ b/static/locales/ko.json @@ -867,7 +867,10 @@ "member_count_other": "구성원 {count}명", "delete_confirm_label": "확인을 위해 그룹 이름을 입력하세요:", "delete_confirm_mismatch": "확인을 위해 그룹 이름을 정확히 입력하세요.", - "virtual_internal_name": "내부" + "virtual_internal_name": "내부", + "members_loading": "구성원 로딩 중…", + "members_empty": "구성원 없음", + "virtual_internal_explanation": "이 서버의 모든 내부 사용자" }, "myshares": { "copyLink": "링크 복사", diff --git a/static/locales/nl.json b/static/locales/nl.json index 33f93ec1..4c54be74 100644 --- a/static/locales/nl.json +++ b/static/locales/nl.json @@ -867,7 +867,10 @@ "member_count_other": "{count} leden", "delete_confirm_label": "Typ de groepsnaam ter bevestiging:", "delete_confirm_mismatch": "Typ de groepsnaam exact om te bevestigen.", - "virtual_internal_name": "Intern" + "virtual_internal_name": "Intern", + "members_loading": "Leden laden…", + "members_empty": "Geen leden", + "virtual_internal_explanation": "Iedere interne gebruiker op deze server" }, "myshares": { "copyLink": "Link kopiëren", diff --git a/static/locales/pl.json b/static/locales/pl.json index e9fff114..3d83473c 100644 --- a/static/locales/pl.json +++ b/static/locales/pl.json @@ -867,7 +867,10 @@ "member_count_other": "{count} członków", "delete_confirm_label": "Wpisz nazwę grupy, aby potwierdzić:", "delete_confirm_mismatch": "Wpisz nazwę grupy dokładnie, aby potwierdzić.", - "virtual_internal_name": "Wewnętrzni" + "virtual_internal_name": "Wewnętrzni", + "members_loading": "Ładowanie członków…", + "members_empty": "Brak członków", + "virtual_internal_explanation": "Każdy użytkownik wewnętrzny na tym serwerze" }, "myshares": { "copyLink": "Skopiuj link", diff --git a/static/locales/pt.json b/static/locales/pt.json index 358ba879..a9a2fa56 100644 --- a/static/locales/pt.json +++ b/static/locales/pt.json @@ -867,7 +867,10 @@ "member_count_other": "{count} membros", "delete_confirm_label": "Digite o nome do grupo para confirmar:", "delete_confirm_mismatch": "Digite o nome do grupo exatamente para confirmar.", - "virtual_internal_name": "Interno" + "virtual_internal_name": "Interno", + "members_loading": "A carregar membros…", + "members_empty": "Sem membros", + "virtual_internal_explanation": "Todos os utilizadores internos neste servidor" }, "myshares": { "copyLink": "Copiar link", diff --git a/static/locales/ru.json b/static/locales/ru.json index c2b1cfec..4786d4f1 100644 --- a/static/locales/ru.json +++ b/static/locales/ru.json @@ -867,7 +867,10 @@ "member_count_other": "{count} участников", "delete_confirm_label": "Введите имя группы для подтверждения:", "delete_confirm_mismatch": "Введите имя группы точно для подтверждения.", - "virtual_internal_name": "Внутренние" + "virtual_internal_name": "Внутренние", + "members_loading": "Загрузка участников…", + "members_empty": "Нет участников", + "virtual_internal_explanation": "Каждый внутренний пользователь на этом сервере" }, "myshares": { "copyLink": "Копировать ссылку", diff --git a/static/locales/zh-TW.json b/static/locales/zh-TW.json index 5d98739c..495218cd 100644 --- a/static/locales/zh-TW.json +++ b/static/locales/zh-TW.json @@ -867,7 +867,10 @@ "member_count_other": "{count} 個成員", "delete_confirm_label": "請輸入群組名稱以確認:", "delete_confirm_mismatch": "請準確輸入群組名稱以確認。", - "virtual_internal_name": "內部" + "virtual_internal_name": "內部", + "members_loading": "正在載入成員…", + "members_empty": "無成員", + "virtual_internal_explanation": "本伺服器上的所有內部使用者" }, "myshares": { "copyLink": "複製連結", diff --git a/static/locales/zh.json b/static/locales/zh.json index 428ce842..3adf619b 100644 --- a/static/locales/zh.json +++ b/static/locales/zh.json @@ -867,7 +867,10 @@ "member_count_other": "{count} 个成员", "delete_confirm_label": "请输入群组名称以确认:", "delete_confirm_mismatch": "请准确输入群组名称以确认。", - "virtual_internal_name": "内部" + "virtual_internal_name": "内部", + "members_loading": "正在加载成员…", + "members_empty": "无成员", + "virtual_internal_explanation": "本服务器上的所有内部用户" }, "myshares": { "copyLink": "复制链接",