Polish desktop sidebar branding and tighten automation rules card.

Move profile and collapse control, show org even for one workspace, and center rules labels in the toolbar-height card.
This commit is contained in:
2026-09-28 17:35:03 +03:30
parent d2c82f2115
commit 589c763ef8
4 changed files with 212 additions and 146 deletions
+19 -3
View File
@@ -31,6 +31,8 @@ const currentName = computed(
() => auth.user?.organizationName || orgs.value.find((o) => o.id === auth.user?.organizationId)?.name || 'محیط کاری',
);
const canSwitch = computed(() => orgs.value.length > 1);
function apiError(e: unknown, fallback: string) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
@@ -48,6 +50,7 @@ async function loadOrgs() {
}
async function toggle() {
if (!canSwitch.value) return;
error.value = '';
if (open.value) {
open.value = false;
@@ -134,12 +137,13 @@ onUnmounted(() => {
</script>
<template>
<div v-if="orgs.length > 1" ref="rootEl" class="org-switch">
<div ref="rootEl" class="org-switch">
<button
class="org-btn"
:class="{ compact }"
:class="{ compact, static: !canSwitch }"
type="button"
:title="currentName"
:aria-disabled="!canSwitch"
@click.stop="toggle"
>
<UiIcon name="building" :size="18" />
@@ -147,7 +151,7 @@ onUnmounted(() => {
</button>
<Teleport to="body">
<div
v-if="open"
v-if="open && canSwitch"
ref="menuEl"
class="menu"
:style="menuStyle"
@@ -234,6 +238,14 @@ onUnmounted(() => {
color: #0f172a;
}
.org-btn.static {
cursor: default;
}
.org-btn.static:hover {
color: #94a3b8;
}
.org-btn.compact {
display: grid;
place-items: center;
@@ -251,6 +263,10 @@ onUnmounted(() => {
background: #f8fafc;
}
.org-btn.compact.static:hover {
background: transparent;
}
.menu {
position: fixed;
z-index: 90;
+2 -2
View File
@@ -24,8 +24,8 @@ withDefaults(
:style="{ width: `${size}px`, height: `${size}px` }"
/>
<div v-if="showText && variant === 'full'" class="text">
<strong>{{ brand.name }}</strong>
<small>by {{ brand.company }}</small>
<strong>{{ brand.nameFa }}</strong>
<small>{{ brand.name }}</small>
</div>
</div>
</template>
+182 -139
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { RouterLink, RouterView, useRoute, useRouter } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
import { api } from '@/lib/api';
@@ -15,7 +15,6 @@ import type {
NotificationDto,
NotificationsListDto,
} from '@mento/shared';
import { brand } from '@mento/shared';
import { canManageSettings, canViewAllRecords } from '@/lib/roles';
const auth = useAuthStore();
@@ -161,41 +160,8 @@ const openHub = computed<NavHubDef | null>(() => {
const hubCanBack = computed(() => hubStack.value.length > 0);
function givenName(fullName: string): string {
const parts = fullName.trim().split(/\s+/).filter(Boolean);
if (parts.length <= 1) return fullName.trim();
return parts.slice(0, -1).join(' ');
}
const profileNameEl = ref<HTMLElement | null>(null);
const useShortProfileName = ref(false);
const profileFullName = computed(() => auth.user?.fullName?.trim() || '');
const profileDisplayName = computed(() => {
if (!profileFullName.value) return '';
return useShortProfileName.value
? givenName(profileFullName.value)
: profileFullName.value;
});
async function fitProfileName() {
if (collapsed.value) {
useShortProfileName.value = false;
return;
}
useShortProfileName.value = false;
await nextTick();
const el = profileNameEl.value;
if (!el) return;
if (el.scrollWidth > el.clientWidth + 1) {
useShortProfileName.value = true;
}
}
let profileNameObserver: ResizeObserver | null = null;
watch([profileFullName, collapsed], () => {
void fitProfileName();
});
const profileDisplayName = computed(() => profileFullName.value);
function closeHub() {
openHubKey.value = null;
@@ -394,14 +360,6 @@ onMounted(() => {
void refreshNotifs();
void loadActivityBadge();
pollTimer = setInterval(() => void refreshNotifs(), 45000);
void fitProfileName();
if (typeof ResizeObserver !== 'undefined') {
profileNameObserver = new ResizeObserver(() => {
void fitProfileName();
});
const meta = profileNameEl.value?.parentElement;
if (meta) profileNameObserver.observe(meta);
}
mobileMq = window.matchMedia('(max-width: 900px)');
isMobileNav.value = mobileMq.matches;
mobileMq.addEventListener('change', onMobileMqChange);
@@ -411,7 +369,6 @@ onUnmounted(() => {
if (pollTimer) clearInterval(pollTimer);
document.body.style.overflow = '';
document.body.classList.remove('mobile-nav-overlay');
profileNameObserver?.disconnect();
mobileMq?.removeEventListener('change', onMobileMqChange);
});
</script>
@@ -422,9 +379,9 @@ onUnmounted(() => {
<aside id="app-sidebar" class="sidebar" :class="{ collapsed }">
<div class="brand-row">
<div class="brand-cluster">
<MentoLogo class="sidebar-logo" :size="32" />
<MentoLogo class="sidebar-logo" :size="52" />
<RouterLink
class="profile-card"
class="profile-card profile-brand"
:to="{ name: 'profile' }"
:title="auth.user?.fullName || 'پروفایل'"
@click="closeMenu"
@@ -441,7 +398,7 @@ onUnmounted(() => {
<i class="online-dot" title="آنلاین" aria-hidden="true" />
</span>
<span class="profile-meta">
<strong ref="profileNameEl">{{ profileDisplayName }}</strong>
<strong>{{ profileDisplayName }}</strong>
</span>
</RouterLink>
</div>
@@ -453,6 +410,16 @@ onUnmounted(() => {
>
✕
</button>
<button
class="collapse-btn desktop-only"
type="button"
:aria-pressed="collapsed"
:aria-label="collapsed ? 'باز کردن منو' : 'جمع کردن منو'"
:title="collapsed ? 'باز کردن منو' : 'جمع کردن منو'"
@click="toggleCollapsed"
>
<UiIcon name="chevron" :size="12" />
</button>
</div>
<div class="rail-stem">
@@ -622,33 +589,42 @@ onUnmounted(() => {
</p>
<div class="sidebar-foot">
<div class="promo-card">
<span class="promo-icons" aria-hidden="true">
<UiIcon name="send" :size="20" />
<UiIcon name="sparkle" :size="18" />
</span>
<p>
<span>با {{ brand.nameFa }} فروشات را</span>
<span>چند برابر کن</span>
</p>
</div>
<div class="foot-actions">
<OrgSwitcher :compact="collapsed" />
<button class="logout-link" type="button" title="خروج از حساب" @click="onLogout">
<UiIcon name="logout" :size="18" />
<span>خروج از حساب</span>
</button>
<RouterLink
class="profile-card profile-foot"
:to="{ name: 'profile' }"
:title="auth.user?.fullName || 'پروفایل'"
@click="closeMenu"
>
<span class="avatar-wrap">
<span class="avatar">
<img
:src="auth.avatarSrc"
:alt="auth.user?.fullName || 'کاربر'"
width="36"
height="36"
/>
</span>
<i class="online-dot" title="آنلاین" aria-hidden="true" />
</span>
<span class="profile-meta">
<strong>{{ profileDisplayName }}</strong>
</span>
</RouterLink>
<div class="foot-meta">
<OrgSwitcher :compact="collapsed" />
<button
class="logout-link"
type="button"
title="خروج از حساب"
aria-label="خروج از حساب"
@click="onLogout"
>
<UiIcon name="logout" :size="18" />
<span>خروج از حساب</span>
</button>
</div>
</div>
<button
class="collapse-btn desktop-only"
type="button"
:aria-pressed="collapsed"
:aria-label="collapsed ? 'باز کردن منو' : 'جمع کردن منو'"
@click="toggleCollapsed"
>
<UiIcon name="chevron" :size="16" />
</button>
</div>
</div>
@@ -977,7 +953,7 @@ onUnmounted(() => {
padding: 0.35rem 0.85rem 1.1rem;
display: flex;
flex-direction: column;
gap: 1.35rem;
gap: 0.65rem;
overflow: hidden;
box-sizing: border-box;
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.06);
@@ -988,7 +964,7 @@ onUnmounted(() => {
flex-direction: column;
flex: 1;
min-height: 0;
gap: inherit;
gap: 0.85rem;
}
.brand-row {
@@ -1009,7 +985,7 @@ onUnmounted(() => {
}
.brand-cluster :deep(.brand-logo.full) {
gap: 0.35rem;
gap: 0.55rem;
flex-shrink: 0;
min-width: max-content;
}
@@ -1023,18 +999,19 @@ onUnmounted(() => {
}
.brand-cluster :deep(.text strong) {
font-size: 0.92rem;
font-size: 1.28rem;
white-space: nowrap;
}
.brand-cluster :deep(.text small) {
font-size: 0.62rem;
font-size: 0.82rem;
white-space: nowrap;
}
.side-nav {
display: grid;
gap: 0.45rem;
gap: 0.18rem;
align-content: start;
overflow-y: auto;
min-height: 0;
flex: 1;
@@ -1049,19 +1026,20 @@ onUnmounted(() => {
.nav-primary,
.nav-sub {
display: grid;
gap: 0.35rem;
gap: 0.2rem;
align-content: start;
}
.nav-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.82rem 1rem;
border-radius: 18px;
gap: 0.65rem;
padding: 0.62rem 0.9rem;
border-radius: 16px;
color: #334155;
text-decoration: none;
font-weight: 600;
font-size: 0.94rem;
font-size: 0.9rem;
transition: background 0.15s ease, color 0.15s ease;
}
@@ -1157,13 +1135,14 @@ onUnmounted(() => {
.nav-sep {
height: 1px;
margin: 0.55rem 0.85rem;
margin: 0.35rem 0.85rem;
background: #e8eef4;
}
.nav-utility {
display: grid;
gap: 0.35rem;
gap: 0.2rem;
align-content: start;
}
.chev {
@@ -1219,6 +1198,11 @@ onUnmounted(() => {
}
.foot-actions {
display: grid;
gap: 0.55rem;
}
.foot-meta {
display: grid;
gap: 0.15rem;
}
@@ -1238,6 +1222,10 @@ onUnmounted(() => {
max-width: 4.8rem;
}
.profile-foot {
display: none;
}
.profile-card:hover {
background: #f0fdfa;
}
@@ -1297,42 +1285,6 @@ onUnmounted(() => {
box-sizing: border-box;
}
.promo-card {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1.05rem 1.05rem;
border-radius: 22px;
background: linear-gradient(105deg, #c4e84a 0%, #2dd4bf 42%, #1d4ed8 100%);
color: #fff;
box-shadow: 0 12px 26px rgba(29, 78, 216, 0.18);
}
.promo-icons {
display: inline-flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.35rem;
flex-shrink: 0;
opacity: 0.95;
}
.promo-card p {
margin: 0;
flex: 1;
min-width: 0;
font-size: 0.98rem;
font-weight: 800;
line-height: 1.5;
display: grid;
gap: 0.1rem;
}
.promo-card p span {
display: block;
}
.logout-link {
display: inline-flex;
align-items: center;
@@ -1351,21 +1303,35 @@ onUnmounted(() => {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
width: 100%;
width: 22px;
height: 22px;
min-height: 0;
padding: 0;
border: 1px solid #e2e8f0;
background: #fff;
color: #64748b;
color: #94a3b8;
font: inherit;
font-size: 0.78rem;
font-weight: 700;
cursor: pointer;
border-radius: 12px;
min-height: 36px;
padding: 0.35rem 0.5rem;
border-radius: 999px;
flex-shrink: 0;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
transition:
background 0.15s ease,
color 0.15s ease,
border-color 0.15s ease,
box-shadow 0.15s ease;
}
.collapse-btn:hover {
background: #f8fafc;
color: #0f172a;
border-color: #cbd5e1;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.08);
}
.collapse-btn :deep(.ico) {
width: 12px;
height: 12px;
transition: transform 0.15s ease;
transform: scaleX(-1);
}
@@ -1425,6 +1391,85 @@ onUnmounted(() => {
}
@media (min-width: 901px) {
.sidebar:not(.collapsed) .profile-brand {
display: none;
}
.sidebar:not(.collapsed) .brand-cluster {
gap: 0;
}
.sidebar:not(.collapsed) .profile-foot {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.65rem;
max-width: none;
flex: 1 1 auto;
width: 100%;
padding: 0.45rem 0.55rem;
border-radius: 14px;
background: #f8fafc;
border: 1px solid #eef2f7;
}
.sidebar:not(.collapsed) .profile-foot:hover {
background: #f0fdfa;
border-color: #ccfbf1;
}
.sidebar:not(.collapsed) .profile-foot .profile-meta {
text-align: start;
flex: 1;
min-width: 0;
}
.sidebar:not(.collapsed) .profile-foot .profile-meta strong {
font-size: 0.84rem;
}
.sidebar:not(.collapsed) .foot-meta {
display: flex;
align-items: center;
gap: 0.45rem;
}
.sidebar:not(.collapsed) .foot-meta :deep(.org-switch) {
flex: 1;
min-width: 0;
}
.sidebar:not(.collapsed) .logout-link {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
border-radius: 12px;
background: #fff;
border: 1px solid #e2e8f0;
color: #64748b;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
flex-shrink: 0;
transition:
background 0.15s ease,
color 0.15s ease,
border-color 0.15s ease,
box-shadow 0.15s ease;
}
.sidebar:not(.collapsed) .logout-link:hover {
background: #fef2f2;
border-color: #fecaca;
color: #dc2626;
box-shadow: 0 4px 12px rgba(220, 38, 38, 0.12);
}
.sidebar:not(.collapsed) .logout-link span {
display: none;
}
.shell.collapsed {
--hub-offset: 72px;
grid-template-columns: 80px minmax(0, 1fr);
@@ -1475,17 +1520,17 @@ onUnmounted(() => {
}
.sidebar.collapsed .brand-cluster :deep(.mark) {
width: 28px !important;
height: 28px !important;
width: 34px !important;
height: 34px !important;
}
.sidebar.collapsed .brand-cluster :deep(.text strong) {
font-size: 0.82rem;
font-size: 1rem;
line-height: 1.05;
}
.sidebar.collapsed .brand-cluster :deep(.text small) {
font-size: 0.58rem;
font-size: 0.7rem;
line-height: 1;
}
@@ -1603,7 +1648,6 @@ onUnmounted(() => {
font-size: 0.58rem;
}
.sidebar.collapsed .promo-card,
.sidebar.collapsed .scope-banner {
display: none;
}
@@ -1635,10 +1679,9 @@ onUnmounted(() => {
}
.sidebar.collapsed .collapse-btn {
min-height: 32px;
font-size: 0.7rem;
padding: 0.15rem;
border-radius: 10px;
width: 20px;
height: 20px;
margin-inline-start: 0.15rem;
}
.sidebar.collapsed .collapse-btn :deep(.ico) {
@@ -567,14 +567,21 @@ watch(trigger, (next) => {
.rules {
grid-area: rules;
/* Let the toolbar set the row height; don't let the rules card grow it. */
/* Match toolbar row height; center content vertically in the card. */
height: 0;
min-height: 100%;
padding: 0.85rem 1rem;
overflow: hidden;
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: center;
align-items: stretch;
gap: 0.35rem;
align-content: center;
}
.rules .muted {
margin: 0;
}
.studio {