diff --git a/modules/addressbook/pages/address-book/index().php b/modules/addressbook/pages/address-book/index().php index 7fb5eb0..9e5a4a2 100644 --- a/modules/addressbook/pages/address-book/index().php +++ b/modules/addressbook/pages/address-book/index().php @@ -84,7 +84,9 @@ $breadcrumbs = [ ['label' => t('Address book')], ]; Buffer::set('grid_lang', json_encode(gridLang(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); -Buffer::set('style_groups', json_encode(['address-book-index'])); +// Drawer fragments render `app-user-profile.phtml` inline on this page, so +// the profile-card CSS (from the `address-book` group) must be loaded too. +Buffer::set('style_groups', json_encode(['address-book-index', 'address-book'])); Buffer::set( 'grid_csrf', json_encode(['key' => $csrfKey, 'token' => $csrfToken], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) diff --git a/modules/addressbook/web/css/pages/address-book-view.css b/modules/addressbook/web/css/pages/address-book-view.css index b8f2f55..1ad273d 100644 --- a/modules/addressbook/web/css/pages/address-book-view.css +++ b/modules/addressbook/web/css/pages/address-book-view.css @@ -1,76 +1,373 @@ @layer pages { -.app-profile-card-container { - margin-top: calc(var(--app-spacing) * 2); -} + /* ────────────────────────────────────────────────────────────────────── + Profile Card (address-book detail view) + Stripe-inspired header: banner + avatar overlap, meta chips, primary + CTAs above the fold, always-visible contact list with copy affordance. + ────────────────────────────────────────────────────────────────────── */ -.app-profile-card { - border: 1px solid var(--app-border); - background: var(--app-card-background-color); - overflow: hidden; - border-radius: var(--app-border-radius); - margin-bottom: var(--app-spacing); -} + .app-profile-card-container { + margin-top: var(--app-spacing); + } -.app-profile-header { - display: flex; - align-items: flex-start; - padding: 0 calc(var(--app-spacing) * 1) 0 calc(var(--app-spacing) * 1); - flex-direction: column; - justify-content: center; - margin-top: -42px; - z-index: 1; - position: relative; -} + .app-profile-card { + border: 1px solid var(--app-border); + background: var(--app-card-background-color); + border-radius: var(--app-border-radius); + margin-bottom: var(--app-spacing); + overflow: hidden; + } -.app-profile-meta h2 { - margin: 0; -} + /* Header grid: avatar | identity | actions. Only the avatar overlaps the + banner via negative margin-top — identity and actions stay at the card's + natural top edge so their vertical rhythm is predictable and doesn't need + hand-tuned padding-top compensation. */ + .app-profile-header { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + grid-template-areas: "avatar identity actions"; + gap: calc(var(--app-spacing) * 1.25); + align-items: start; + padding: calc(var(--app-spacing) * 1.25); + position: relative; + z-index: 1; + } -.app-profile-meta p { - margin: 0.25rem 0 0; - color: var(--app-muted-color); -} + .app-profile-avatar { + grid-area: avatar; + margin-top: -48px; + } -.app-profile-body { - border: 1px solid var(--app-border); - background: var(--app-card-background-color); - overflow: hidden; - border-radius: var(--app-border-radius); - margin-bottom: var(--app-spacing); -} + .app-profile-identity { + grid-area: identity; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.6rem; + padding-top: 0.15rem; + } -.app-profile-body [data-tab-panel] { - padding-inline: var(--app-spacing); -} + .app-profile-identity-name { + margin: 0; + } -.app-profile-body [data-tab-panel]:not(:has(p)) { - padding-block-end: var(--app-spacing); -} + .app-profile-identity-name h2 { + margin: 0; + font-size: 1.6rem; + font-weight: 600; + line-height: 1.2; + letter-spacing: -0.01em; + } -.app-profile-table { - width: 100%; - border: 1px solid var(--app-border); - border-radius: var(--app-border-radius); - border-collapse: separate; - border-spacing: 0; - background: transparent; - overflow: hidden; -} + .app-profile-identity-name p { + margin: 0.15rem 0 0; + color: var(--app-muted-color); + font-size: 0.95rem; + font-weight: 500; + } -.app-profile-table th, -.app-profile-table td { - padding: 0.55rem 0.75rem; - background: transparent; - border-bottom: 1px solid var(--app-border); - text-align: left; - vertical-align: top; -} + /* Chip container — core `.badge` already owns display/gap/padding/radius. + We only define row/wrap layout and a slightly richer `small` inside the + primary-tenant chip to soften the "· Primary" suffix. */ + .app-profile-chips { + display: inline-flex; + flex-wrap: wrap; + gap: 0.4rem; + margin: 0; + } -.app-profile-table thead th { - font-weight: 600; -} + .app-profile-chips .badge small { + opacity: 0.75; + } -.app-profile-table tr:last-child td { - border-bottom: 0; -} + .app-profile-actions { + grid-area: actions; + display: inline-flex; + gap: 0.4rem; + flex-shrink: 0; + padding-top: 0.15rem; + } + + .app-profile-action-button { + display: inline-flex; + align-items: center; + gap: 0.35rem; + text-decoration: none; + white-space: nowrap; + } + + /* ────────────────────────────────────────────────────────────────────── + Always-visible Contact list with copy affordance + ────────────────────────────────────────────────────────────────────── */ + + /* Stripe-style contact list: vertical stack, icon + value only (label + communicated by icon), entire row is the action target. Subtle hairline + dividers between rows keep density high and reading quick. */ + .app-profile-contact-list { + list-style: none; + margin: 0; + padding: 0; + border-top: 1px solid var(--app-border); + } + + .app-profile-contact-row { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.15rem calc(var(--app-spacing) * 1.25); + min-width: 0; + } + + .app-profile-contact-row + .app-profile-contact-row { + border-top: 1px solid var(--app-border); + } + + .app-profile-contact-value { + display: inline-flex; + align-items: center; + gap: 0.7rem; + flex: 1 1 auto; + min-width: 0; + padding: 0.55rem 0; + color: var(--app-color); + text-decoration: none; + font-weight: 500; + font-variant-numeric: tabular-nums; + } + + .app-profile-contact-icon { + color: var(--app-muted-color); + font-size: 1rem; + line-height: 1; + flex-shrink: 0; + transition: color 0.12s ease; + } + + .app-profile-contact-text { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .app-profile-contact-value:hover, + .app-profile-contact-value:focus-visible { + color: var(--app-primary); + outline: none; + } + + .app-profile-contact-value:hover .app-profile-contact-icon, + .app-profile-contact-value:focus-visible .app-profile-contact-icon { + color: var(--app-primary); + } + + .app-profile-contact-value:hover .app-profile-contact-text, + .app-profile-contact-value:focus-visible .app-profile-contact-text { + text-decoration: underline; + text-underline-offset: 3px; + } + + /* Copy button — hover-revealed on pointer devices, always visible on touch + (no :hover) so the affordance stays discoverable. */ + .app-copy-button { + --app-background-color: transparent; + --app-border-color: transparent; + --app-color: var(--app-muted-color); + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + padding: 0; + margin: 0; + border-radius: calc(var(--app-border-radius) * 0.8); + cursor: pointer; + opacity: 0; + transition: opacity 0.12s ease, background-color 0.12s ease, color 0.12s ease; + } + + .app-profile-contact-row:hover .app-copy-button, + .app-profile-contact-row:focus-within .app-copy-button, + .app-copy-button:focus-visible { + opacity: 1; + } + + @media (hover: none) { + .app-copy-button { + opacity: 1; + } + } + + .app-copy-button:hover, + .app-copy-button:focus-visible { + --app-background-color: color-mix(in srgb, var(--app-primary) 10%, transparent); + --app-color: var(--app-primary); + } + + .app-copy-button [data-copy-icon-done] { + display: none; + } + + .app-copy-button.is-copied { + --app-color: var(--app-primary); + opacity: 1; + } + + .app-copy-button.is-copied [data-copy-icon-idle] { + display: none; + } + + .app-copy-button.is-copied [data-copy-icon-done] { + display: inline-block; + } + + /* ────────────────────────────────────────────────────────────────────── + Tabs — stronger typography + primary-colored active indicator + ────────────────────────────────────────────────────────────────────── */ + + .app-profile-body { + border: 1px solid var(--app-border); + background: var(--app-card-background-color); + border-radius: var(--app-border-radius); + margin-bottom: var(--app-spacing); + overflow: hidden; + } + + .app-profile-tabs [data-tab-panel] { + padding: var(--app-spacing) calc(var(--app-spacing) * 1.25); + } + + .app-profile-panel { + min-height: 0; + } + + .app-profile-tabs .app-tabs-nav button[data-tab] { + font-weight: 500; + } + + .app-profile-tabs .app-tabs-nav button[data-tab][aria-selected="true"], + .app-profile-tabs .app-tabs-nav button[data-tab].is-active { + font-weight: 600; + } + + /* Address block — semantic element, block layout */ + .app-profile-address { + display: flex; + flex-direction: column; + gap: 0.2rem; + font-style: normal; + line-height: 1.45; + } + + .app-profile-key-value { + margin-bottom: 0.9rem; + } + + .app-profile-key-value:last-child { + margin-bottom: 0; + } + + .app-profile-key-value small { + display: block; + color: var(--app-muted-color); + font-size: 0.78rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.04em; + margin-bottom: 0.15rem; + } + + .app-profile-key-value p { + margin: 0; + font-weight: 500; + } + + .app-profile-about-description p { + margin: 0 0 0.4rem; + } + + .app-profile-about-description p:last-child { + margin-bottom: 0; + } + + /* ────────────────────────────────────────────────────────────────────── + Organization tab — multi-tenant overview + ────────────────────────────────────────────────────────────────────── */ + + .app-profile-tenant-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.9rem; + } + + .app-profile-tenant-item { + display: flex; + flex-direction: column; + gap: 0.15rem; + } + + .app-profile-tenant-label { + display: inline-flex; + align-items: baseline; + gap: 0.4rem; + font-size: 0.95rem; + } + + .app-profile-tenant-label strong { + font-weight: 600; + } + + .app-profile-tenant-primary { + color: var(--app-muted-color); + } + + .app-profile-tenant-departments { + color: var(--app-muted-color); + font-size: 0.88rem; + padding-left: 1.3rem; + } + + /* ────────────────────────────────────────────────────────────────────── + Mobile — stack header, show copy buttons, keep chips readable + ────────────────────────────────────────────────────────────────────── */ + + @media (max-width: 768px) { + .app-profile-header { + grid-template-columns: auto 1fr; + grid-template-areas: + "avatar identity" + "actions actions"; + row-gap: 0.9rem; + padding: var(--app-spacing); + } + + .app-profile-avatar { + margin-top: -36px; + } + + .app-profile-identity { + padding-top: 0; + } + + .app-profile-actions { + padding-top: 0; + flex-wrap: wrap; + width: 100%; + } + + .app-profile-action-button { + flex: 1 1 auto; + justify-content: center; + } + + .app-profile-contact-row { + padding-inline: var(--app-spacing); + } + + .app-profile-identity-name h2 { + font-size: 1.35rem; + } + } } diff --git a/templates/partials/app-user-profile.phtml b/templates/partials/app-user-profile.phtml index 62580b3..f6efb26 100644 --- a/templates/partials/app-user-profile.phtml +++ b/templates/partials/app-user-profile.phtml @@ -47,20 +47,31 @@ if ($displayName !== '') { $initials = strtoupper($chars !== '' ? $chars : '?'); } $tenantGroups = $values['tenant_groups'] ?? []; -$hasContact = ($email !== '' || $phone !== '' || $mobile !== '' || $shortDial !== ''); -$hasAddress = ($address !== '' || $postalCode !== '' || $city !== '' || $region !== '' || $country !== ''); -$hasOrganization = (!empty($tenantGroups)); -$aboutSummary = ''; -if ($profileDescription !== '') { - foreach (preg_split('/\\r\\n|\\r|\\n/', $profileDescription) as $line) { - $line = trim((string) $line); - if ($line !== '') { - $aboutSummary = $line; - break; - } +$tenantGroups = is_array($tenantGroups) ? $tenantGroups : []; +$primaryTenantLabel = ''; +$primaryTenantDepartments = []; +foreach ($tenantGroups as $group) { + if (!empty($group['is_primary'])) { + $primaryTenantLabel = trim((string) ($group['label'] ?? '')); + $primaryDepts = $group['departments'] ?? []; + $primaryTenantDepartments = is_array($primaryDepts) ? $primaryDepts : []; + break; } } +$hasContact = ($email !== '' || $phone !== '' || $mobile !== ''); +$hasAddress = ($address !== '' || $postalCode !== '' || $city !== '' || $region !== '' || $country !== ''); +// Organization tab only appears for multi-tenant users — single-tenant info +// is already communicated through the header chips, and redundancy hurts more +// than a minor asymmetry. +$hasOrganization = count($tenantGroups) > 1; +$hasAbout = ($profileDescription !== '' || $hireDateLabel !== '' || $shortDial !== ''); +$hasAnyTab = ($hasAddress || $hasAbout || $hasOrganization); + +$copyEmailLabel = t('Copy email address'); +$copyPhoneLabel = t('Copy phone number'); +$copyMobileLabel = t('Copy mobile number'); + ?>
@@ -80,164 +91,220 @@ if ($profileDescription !== '') {
-
-
+ +
+

-

- -

+

-
-
- - -
-
-
-
- - - - - - - - - - -
- -
-
- -
- -

-
+ + +
+ + + + + · + - -
- -

-
+ + + + + + + + + + +
-
- -
- -

+ +
+ + +
+ + + + + + + + + + + + + +
+ +
+ + + + +
+
+ + +
+
+
+
+ ' + . htmlspecialchars($label, ENT_QUOTES, 'UTF-8') . ''; + }; + ?> + + + +
+ + +
+
+ + + + + + + + + + + + + +
+
+ + + +
+
    + + +
  • +
    + + + + · + +
    + +
    + +
    + +
  • + +
+
+ + + +
+ +
+ +

-
+

-
-
- - -
-
- -

- - - -

- - -

- - -

- -
-
- - -
- - - - - - - - - - - - - - - + +
+ + +

+ -
-
- - - () - - - - - - - - -
- -

-

- -
- -
- -
- -

-
- - - - -

+
- - -

-

+
-
+ -
diff --git a/web/js/app-init.js b/web/js/app-init.js index 386f13f..0c21400 100644 --- a/web/js/app-init.js +++ b/web/js/app-init.js @@ -4,6 +4,7 @@ import { warnOnce } from './core/app-dom.js'; import { initFlashAutoDismiss } from './components/app-flash-auto-dismiss.js'; import { initPasswordHints } from './components/app-password-hints.js'; import { initBadgeCopy } from './components/app-copy-badge.js'; +import { initCopyField } from './components/app-copy-field.js'; import { initMultiSelectComponent } from './components/app-multiselect-init.js'; import { initSidebarToggle } from './components/app-toggle-sidebar.js'; import { initAsidePanels } from './components/app-toggle-aside-panels.js'; @@ -174,6 +175,10 @@ const bootRuntime = async () => { scope: 'global', configPath: 'components.copyBadge', }); + runtime.register('copy-field', initCopyField, { + scope: 'global', + configPath: 'components.copyField', + }); runtime.register('multiselect', initMultiSelectComponent, { scope: 'global', configPath: 'components.multiSelect', diff --git a/web/js/components/app-copy-field.js b/web/js/components/app-copy-field.js new file mode 100644 index 0000000..0fa91f7 --- /dev/null +++ b/web/js/components/app-copy-field.js @@ -0,0 +1,101 @@ +/** + * Copy-to-clipboard button — click writes `data-copy-value` to the clipboard + * and swaps the button icon to a check for ~1.2s. Self-contained: markup is + * fully rendered server-side, this component only wires the click handler. + * + * + */ +import { resolveHost } from '../core/app-dom.js'; + +const BUTTON_SELECTOR = '[data-copy-button]'; +const DONE_CLASS = 'is-copied'; +const DONE_TIMEOUT_MS = 1200; + +const copyText = async (value) => { + if (!value) {return false;} + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(value); + return true; + } catch { + return false; + } + } + const textarea = document.createElement('textarea'); + textarea.value = value; + textarea.setAttribute('readonly', 'true'); + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.select(); + let ok = false; + try { + ok = document.execCommand('copy'); + } catch { + ok = false; + } + document.body.removeChild(textarea); + return ok; +}; + +export function initCopyField(root = document, options = {}) { + const selector = String(options.selector || BUTTON_SELECTOR).trim() || BUTTON_SELECTOR; + const host = resolveHost(root); + const buttons = Array.from(host.querySelectorAll(selector)); + if (!buttons.length) { + return { destroy: () => {} }; + } + + const cleanupFns = []; + const timers = new Map(); + + buttons.forEach((button) => { + if (!(button instanceof HTMLElement) || button.dataset.copyButtonBound === '1') { + return; + } + button.dataset.copyButtonBound = '1'; + + // Clipboard API is unavailable in insecure contexts (http without localhost) + // and the execCommand fallback is deprecated. If neither is usable, hide + // the button entirely rather than show a non-functional control. + const clipboardUsable = typeof navigator.clipboard?.writeText === 'function' + || typeof document.execCommand === 'function'; + if (!clipboardUsable) { + button.hidden = true; + return; + } + + const onClick = async (event) => { + event.preventDefault(); + const value = button.dataset.copyValue || ''; + const ok = await copyText(value); + if (!ok) {return;} + button.classList.add(DONE_CLASS); + const prevTimer = timers.get(button); + if (prevTimer) {window.clearTimeout(prevTimer);} + const timer = window.setTimeout(() => { + button.classList.remove(DONE_CLASS); + timers.delete(button); + }, DONE_TIMEOUT_MS); + timers.set(button, timer); + }; + + button.addEventListener('click', onClick); + cleanupFns.push(() => { + button.removeEventListener('click', onClick); + delete button.dataset.copyButtonBound; + }); + }); + + const destroy = () => { + cleanupFns.forEach((fn) => fn()); + timers.forEach((timer) => window.clearTimeout(timer)); + timers.clear(); + }; + + return { destroy }; +}