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'); + ?>
| - | - |
|---|---|
| - - - () - - | -- - - - - - - | -
-
- --
+