Files
social-media/frontend/src/layouts/main/AppSidebar.vue

949 lines
32 KiB
Vue

<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAuthStore } from '@/features/auth/stores/authStore.js';
import { useChannelsStore } from '@/features/channels/stores/channelsStore.js';
import { useNotificationsStore } from '@/features/notifications/stores/notificationsStore.js';
import { getNotificationRoute } from '@/features/notifications/notificationRoutes.js';
import { useContentItemsStore } from '@/features/content/stores/contentItemsStore.js';
import { useCampaignsStore } from '@/features/campaigns/stores/campaignsStore.js';
import SidebarUserMenu from './SidebarUserMenu.vue';
import {
mdiBellOutline,
mdiCalendarMonthOutline,
mdiChevronDown,
mdiFolderOutline,
mdiHomeOutline,
mdiImageMultipleOutline,
mdiLan,
mdiMagnify,
mdiPlus,
mdiBugOutline,
} from '@mdi/js';
const props = defineProps({
isExpanded: {
type: Boolean,
default: true,
},
});
const router = useRouter();
const { t } = useI18n();
const route = useRoute();
const authStore = useAuthStore();
const channelsStore = useChannelsStore();
const contentItemsStore = useContentItemsStore();
const notificationsStore = useNotificationsStore();
const campaignsStore = useCampaignsStore();
const isNotificationsOpen = ref(false);
const isSearchFocused = ref(false);
const searchQuery = ref('');
const notificationsRef = ref(null);
const searchRef = ref(null);
const collapsedSearchInputRef = ref(null);
const collapsedSearchPanelStyle = ref({});
const primaryLinks = [
{ to: '/app/dashboard', labelKey: 'nav.overview', icon: mdiHomeOutline },
{ to: '/app/media-library', labelKey: 'nav.mediaLibrary', icon: mdiImageMultipleOutline },
{ to: '/app/feedback', labelKey: 'nav.feedbackReview', icon: mdiBugOutline, roles: ['developer'] },
];
const visiblePrimaryLinks = computed(() =>
primaryLinks.filter(link => !link.roles || authStore.hasAnyRole(link.roles))
);
const openSections = ref({
channels: false,
campaigns: false,
});
const normalizedSearchQuery = computed(() => searchQuery.value.trim().toLowerCase());
const campaignResults = computed(() => {
if (!normalizedSearchQuery.value) {
return [];
}
return campaignsStore.campaigns
.filter(campaign => campaign.name.toLowerCase().includes(normalizedSearchQuery.value))
.slice(0, 5)
.map(campaign => ({
id: campaign.id,
label: campaign.name,
description: 'Campaign',
route: { name: 'campaign-detail', params: { campaignId: campaign.id } },
}));
});
const contentResults = computed(() => {
if (!normalizedSearchQuery.value) {
return [];
}
return contentItemsStore.items
.filter(item => {
const titleMatch = item.title.toLowerCase().includes(normalizedSearchQuery.value);
const hashtagMatch = (item.hashtags ?? '').toLowerCase().includes(normalizedSearchQuery.value);
return titleMatch || hashtagMatch;
})
.slice(0, 6)
.map(item => ({
id: item.id,
label: item.title,
description: item.hashtags || item.publicationTargets,
route: { name: 'content-item-detail', params: { id: item.id } },
}));
});
const hasSearchResults = computed(() =>
campaignResults.value.length > 0 || contentResults.value.length > 0
);
const isSearchOpen = computed(() => isSearchFocused.value && normalizedSearchQuery.value.length > 0);
const isSearchPanelOpen = computed(() =>
isSearchFocused.value && (!props.isExpanded || normalizedSearchQuery.value.length > 0)
);
const notificationTitleMap = computed(() => ({
'approval.requested': t('notifications.events.approvalRequested'),
'approval.decision.recorded': t('notifications.events.approvalDecisionRecorded'),
'comment.created': t('notifications.events.commentCreated'),
'comment.resolved': t('notifications.events.commentResolved'),
'content-item.created': t('notifications.events.contentCreated'),
'content-item.revision.created': t('notifications.events.revisionCreated'),
'content-item.status.updated': t('notifications.events.statusUpdated'),
'asset.google-drive-linked': t('notifications.events.assetLinked'),
'asset.revision.created': t('notifications.events.assetRevisionCreated'),
'Feedback.ReportCreated': t('notifications.events.feedbackReportCreated'),
'Feedback.DeveloperCommented': t('notifications.events.feedbackDeveloperCommented'),
'Feedback.StatusChanged': t('notifications.events.feedbackStatusChanged'),
'Feedback.ReporterCommented': t('notifications.events.feedbackReporterCommented'),
}));
function toggleSection(sectionName) {
openSections.value[sectionName] = !openSections.value[sectionName];
}
function toggleNotifications() {
isNotificationsOpen.value = !isNotificationsOpen.value;
}
function updateCollapsedSearchPanelPosition() {
if (props.isExpanded || !searchRef.value) {
collapsedSearchPanelStyle.value = {};
return;
}
const rect = searchRef.value.getBoundingClientRect();
const left = rect.right + 12;
const availableWidth = Math.max(240, window.innerWidth - left - 12);
collapsedSearchPanelStyle.value = {
left: `${left}px`,
top: `${Math.max(12, rect.top)}px`,
width: `${Math.min(352, availableWidth)}px`,
};
}
async function openCollapsedSearch() {
if (props.isExpanded) {
return;
}
isSearchFocused.value = true;
updateCollapsedSearchPanelPosition();
await nextTick();
collapsedSearchInputRef.value?.focus();
}
function formatNotificationTitle(notification) {
return notificationTitleMap.value[notification.eventType] ?? notification.message;
}
function formatNotificationDate(value) {
if (!value) {
return '';
}
return new Date(value).toLocaleString();
}
async function openNotification(notification) {
if (!notification.readAt) {
await notificationsStore.markAsRead(notification.id);
}
isNotificationsOpen.value = false;
const notificationRoute = getNotificationRoute(notification, authStore);
if (notificationRoute) {
await router.push(notificationRoute);
}
}
async function openSearchResult(result) {
isSearchFocused.value = false;
await router.push(result.route);
}
function handleDocumentClick(event) {
if (searchRef.value && !searchRef.value.contains(event.target)) {
isSearchFocused.value = false;
}
if (isNotificationsOpen.value && notificationsRef.value && !notificationsRef.value.contains(event.target)) {
isNotificationsOpen.value = false;
}
}
watch(
() => route.path,
path => {
if (path.startsWith('/app/channels')) {
openSections.value.channels = true;
}
if (path.startsWith('/app/campaigns')) {
openSections.value.campaigns = true;
}
},
{ immediate: true }
);
watch(isSearchPanelOpen, isOpen => {
if (isOpen) {
updateCollapsedSearchPanelPosition();
}
});
watch(
() => props.isExpanded,
isExpanded => {
if (isExpanded) {
collapsedSearchPanelStyle.value = {};
return;
}
isNotificationsOpen.value = false;
updateCollapsedSearchPanelPosition();
}
);
onMounted(() => {
document.addEventListener('click', handleDocumentClick);
window.addEventListener('resize', updateCollapsedSearchPanelPosition);
window.addEventListener('scroll', updateCollapsedSearchPanelPosition, true);
});
onBeforeUnmount(() => {
document.removeEventListener('click', handleDocumentClick);
window.removeEventListener('resize', updateCollapsedSearchPanelPosition);
window.removeEventListener('scroll', updateCollapsedSearchPanelPosition, true);
});
</script>
<template>
<aside
class="app-sidebar"
:class="{ 'app-sidebar-collapsed': !isExpanded }"
>
<div class="brand-block">
<router-link
class="brand-link"
:class="{ 'brand-link-collapsed': !isExpanded }"
to="/"
>
<span class="brand-mark">S</span>
<div
v-if="isExpanded"
class="brand-copy"
>
<div class="brand-heading">
<span class="brand-name-wrap">
<span class="brand-name">Socialize</span>
<span
class="brand-stage-badge"
:aria-label="t('nav.brandStageLabel')"
>
<span>{{ t('nav.brandStage') }}</span>
</span>
</span>
</div>
</div>
</router-link>
</div>
<div class="app-sidebar-scroll">
<div
v-if="authStore.isAuthenticated"
class="sidebar-section sidebar-utilities"
>
<div
ref="searchRef"
class="sidebar-search-wrap"
>
<label
class="sidebar-search"
:class="{ 'sidebar-search-open': isSearchOpen }"
:title="!isExpanded ? 'Search' : null"
@click="openCollapsedSearch"
>
<v-icon
:icon="mdiMagnify"
class="sidebar-search-icon"
/>
<input
v-if="isExpanded"
v-model="searchQuery"
type="search"
class="sidebar-search-input"
placeholder="Search"
@focus="isSearchFocused = true"
/>
</label>
<div
v-if="isSearchPanelOpen"
class="sidebar-floating-panel"
:class="{ 'sidebar-search-panel-collapsed': !isExpanded }"
:style="!isExpanded ? collapsedSearchPanelStyle : null"
>
<label
v-if="!isExpanded"
class="sidebar-search sidebar-search-panel-input"
>
<v-icon
:icon="mdiMagnify"
class="sidebar-search-icon"
/>
<input
ref="collapsedSearchInputRef"
v-model="searchQuery"
type="search"
class="sidebar-search-input"
placeholder="Search"
/>
</label>
<div
v-if="campaignResults.length"
class="sidebar-search-group"
>
<strong>Campaigns</strong>
<button
v-for="result in campaignResults"
:key="`campaign-${result.id}`"
class="sidebar-search-result"
@click="openSearchResult(result)"
>
<span>{{ result.label }}</span>
<small>{{ result.description }}</small>
</button>
</div>
<div
v-if="contentResults.length"
class="sidebar-search-group"
>
<strong>Content items</strong>
<button
v-for="result in contentResults"
:key="`content-${result.id}`"
class="sidebar-search-result"
@click="openSearchResult(result)"
>
<span>{{ result.label }}</span>
<small>{{ result.description }}</small>
</button>
</div>
<div
v-if="normalizedSearchQuery.length > 0 && !hasSearchResults"
class="sidebar-search-empty"
>
No results found.
</div>
</div>
</div>
<div
ref="notificationsRef"
class="sidebar-notifications-wrap"
>
<button
class="sidebar-link sidebar-utility-link"
type="button"
@click.stop="toggleNotifications"
>
<span class="sidebar-link-main">
<span class="sidebar-notification-icon-wrap">
<v-icon :icon="mdiBellOutline" />
<span
v-if="notificationsStore.unreadCount"
class="sidebar-notification-badge"
>
{{ Math.min(notificationsStore.unreadCount, 9) }}
</span>
</span>
<span
v-if="isExpanded"
class="sidebar-link-label"
>
{{ t('notifications.title') }}
</span>
</span>
</button>
<div
v-if="isExpanded && isNotificationsOpen"
class="sidebar-floating-panel sidebar-notifications-panel"
>
<div class="sidebar-notifications-header">
<strong>{{ t('notifications.title') }}</strong>
<span>{{ notificationsStore.unreadCount }} {{ t('notifications.unread') }}</span>
</div>
<div
v-if="notificationsStore.isLoading"
class="sidebar-notifications-empty"
>
{{ t('notifications.loading') }}
</div>
<div
v-else-if="notificationsStore.error"
class="sidebar-notifications-empty"
>
{{ notificationsStore.error }}
</div>
<button
v-for="notification in notificationsStore.recentItems"
:key="notification.id"
class="sidebar-notification-row"
:class="{ 'sidebar-notification-row-unread': !notification.readAt }"
@click="openNotification(notification)"
>
<strong>{{ formatNotificationTitle(notification) }}</strong>
<span>{{ notification.message }}</span>
<small>{{ formatNotificationDate(notification.createdAt) }}</small>
</button>
<div
v-if="!notificationsStore.isLoading && !notificationsStore.recentItems.length"
class="sidebar-notifications-empty"
>
{{ t('notifications.empty') }}
</div>
</div>
</div>
</div>
<div class="sidebar-section">
<router-link
v-for="link in visiblePrimaryLinks"
:key="link.to"
:to="link.to"
class="sidebar-link"
active-class="sidebar-link-active"
:title="!isExpanded ? t(link.labelKey) : null"
>
<v-icon :icon="link.icon" />
<span
v-if="isExpanded"
class="sidebar-link-label"
>
{{ t(link.labelKey) }}
</span>
</router-link>
</div>
<div class="sidebar-section">
<div class="sidebar-section-header">
<router-link
to="/app/content"
class="sidebar-link sidebar-link-section"
active-class="sidebar-link-active"
:title="!isExpanded ? t('nav.content') : null"
>
<span class="sidebar-link-main">
<v-icon :icon="mdiCalendarMonthOutline" />
<span
v-if="isExpanded"
class="sidebar-link-label"
>
{{ t('nav.content') }}
</span>
</span>
</router-link>
<router-link
v-if="isExpanded"
:to="{ name: 'content-item-create' }"
class="sidebar-section-action"
:title="t('contentItems.newItem')"
>
<v-icon :icon="mdiPlus" />
</router-link>
</div>
</div>
<div class="sidebar-section">
<div class="sidebar-section-header">
<router-link
to="/app/campaigns"
class="sidebar-link sidebar-link-section"
active-class="sidebar-link-active"
:title="!isExpanded ? t('nav.campaigns') : null"
@click="toggleSection('campaigns')"
>
<span class="sidebar-link-main">
<v-icon :icon="mdiFolderOutline" />
<span
v-if="isExpanded"
class="sidebar-link-label"
>
{{ t('nav.campaigns') }}
</span>
<v-icon
v-if="isExpanded"
:icon="mdiChevronDown"
class="sidebar-chevron"
:class="{ 'sidebar-chevron-open': openSections.campaigns }"
/>
</span>
</router-link>
<router-link
v-if="isExpanded"
to="/app/campaigns?create=true"
class="sidebar-section-action"
:title="t('campaigns.createTitle')"
>
<v-icon :icon="mdiPlus" />
</router-link>
</div>
<div
v-if="isExpanded && openSections.campaigns"
class="sidebar-sublist"
>
<router-link
to="/app/campaigns"
class="sidebar-sublink sidebar-sublink-overview"
active-class="sidebar-sublink-active"
>
<span>{{ t('sidebar.allCampaigns') }}</span>
</router-link>
<router-link
v-for="campaign in campaignsStore.campaigns"
:key="campaign.id"
:to="{ name: 'campaign-detail', params: { campaignId: campaign.id } }"
class="sidebar-sublink"
active-class="sidebar-sublink-active"
>
<span>{{ campaign.name }}</span>
</router-link>
<div
v-if="!campaignsStore.campaigns.length"
class="sidebar-empty"
>
{{ t('sidebar.noCampaigns') }}
</div>
</div>
</div>
<div class="sidebar-section">
<div class="sidebar-section-header">
<router-link
to="/app/channels"
class="sidebar-link sidebar-link-section"
active-class="sidebar-link-active"
:title="!isExpanded ? t('nav.channels') : null"
@click="toggleSection('channels')"
>
<span class="sidebar-link-main">
<v-icon :icon="mdiLan" />
<span
v-if="isExpanded"
class="sidebar-link-label"
>
{{ t('nav.channels') }}
</span>
<v-icon
v-if="isExpanded"
:icon="mdiChevronDown"
class="sidebar-chevron"
:class="{ 'sidebar-chevron-open': openSections.channels }"
/>
</span>
</router-link>
<router-link
v-if="isExpanded"
to="/app/channels?create=true"
class="sidebar-section-action"
:title="t('channels.createTitle')"
>
<v-icon :icon="mdiPlus" />
</router-link>
</div>
<div
v-if="isExpanded && openSections.channels"
class="sidebar-sublist"
>
<router-link
to="/app/channels"
class="sidebar-sublink sidebar-sublink-overview"
active-class="sidebar-sublink-active"
>
<span>{{ t('sidebar.allChannels') }}</span>
</router-link>
<router-link
v-for="channel in channelsStore.channels"
:key="channel.id"
:to="{ name: 'channels', query: { channel: channel.id } }"
class="sidebar-sublink"
active-class="sidebar-sublink-active"
>
<span>{{ channel.name }}</span>
</router-link>
<div
v-if="!channelsStore.channels.length"
class="sidebar-empty"
>
{{ t('sidebar.noChannels') }}
</div>
</div>
</div>
</div>
<SidebarUserMenu
v-if="authStore.isAuthenticated"
:is-expanded="isExpanded"
/>
</aside>
</template>
<style scoped>
.app-sidebar {
@apply flex h-full w-[19rem] flex-shrink-0 flex-col px-4 pt-4 transition-[width,padding] duration-200;
border-right: 1px solid rgba(23, 32, 51, 0.08);
}
.app-sidebar-scroll {
@apply flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto pb-4 pt-4;
}
.brand-block {
@apply flex items-center gap-3 pb-4;
border-bottom: 1px solid rgba(23, 32, 51, 0.08);
}
.brand-link {
@apply flex min-w-0 items-start gap-3 no-underline;
color: inherit;
}
.brand-link-collapsed {
@apply w-full justify-center;
}
.brand-copy {
@apply min-w-0;
}
.brand-heading {
@apply flex min-w-0 items-center;
}
.brand-mark {
@apply flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-[1.1rem] text-xl font-black;
background: var(--socialize-brand-gradient);
color: #fffaf2;
}
.brand-name-wrap {
@apply relative inline-flex min-w-0 items-center;
}
.brand-name {
@apply min-w-0 text-lg font-black uppercase tracking-[0.18em];
color: #172033;
line-height: 2.75rem;
}
.brand-stage-badge {
@apply absolute inline-flex h-4 items-center rounded-sm border px-1.5 text-[0.55rem] font-black uppercase tracking-[0.08em];
background: rgba(255, 231, 199, 0.46);
border-color: rgba(242, 179, 107, 0.38);
color: #925000;
left: 0;
line-height: 1;
top: calc(100% - 0.45rem);
}
.side-menu {
@apply flex flex-1 items-center justify-between gap-3;
}
.sidebar-utilities {
@apply gap-3 pb-1;
}
.sidebar-search-wrap,
.sidebar-notifications-wrap {
@apply relative;
}
.sidebar-search {
@apply flex h-11 items-center gap-3 rounded-[1.1rem] border px-4 transition-colors;
background: rgba(23, 32, 51, 0.04);
border-color: rgba(23, 32, 51, 0.06);
color: #526178;
}
.sidebar-search-open,
.sidebar-search:focus-within {
background: rgba(255, 255, 255, 0.96);
border-color: rgba(23, 32, 51, 0.1);
}
.sidebar-search-icon {
@apply h-5 w-5 flex-shrink-0 text-xl;
}
.sidebar-search-input {
@apply min-w-0 flex-1 border-0 bg-transparent p-0 text-sm;
color: #172033;
outline: none;
}
.sidebar-search-input::placeholder {
color: #7a8799;
}
.sidebar-floating-panel {
@apply absolute left-0 right-0 top-[calc(100%+0.6rem)] z-40 flex flex-col gap-3 rounded-[1.25rem] border p-3;
background: rgba(255, 255, 255, 0.98);
border-color: rgba(23, 32, 51, 0.08);
box-shadow: 0 18px 40px rgba(23, 32, 51, 0.12);
}
.sidebar-search-panel-collapsed {
@apply fixed right-auto top-auto;
}
.sidebar-search-panel-input {
@apply bg-white;
}
.sidebar-search-group {
@apply flex flex-col gap-1;
}
.sidebar-search-group strong {
@apply px-2 text-xs font-black uppercase tracking-[0.18em];
color: #5d6b82;
}
.sidebar-search-result {
@apply flex flex-col gap-1 rounded-[0.95rem] px-3 py-3 text-left transition-colors;
color: #172033;
}
.sidebar-search-result:hover {
background: rgba(23, 32, 51, 0.06);
}
.sidebar-search-result small,
.sidebar-search-empty {
@apply text-xs leading-5;
color: #526178;
}
.sidebar-search-empty {
@apply px-2 py-1;
}
.sidebar-utility-link {
@apply w-full justify-between text-left;
}
.sidebar-notification-icon-wrap {
@apply relative flex items-center justify-center;
}
.sidebar-notification-badge {
@apply absolute -right-2 -top-2 flex h-5 min-w-[1.25rem] items-center justify-center rounded-full px-1 text-[10px] font-black;
background: #ef4444;
color: #fffaf2;
}
.sidebar-notifications-panel {
@apply gap-1;
}
.sidebar-notifications-header {
@apply mb-1 flex items-center justify-between gap-3 px-3 py-2;
}
.sidebar-notifications-header strong {
@apply text-sm font-black;
color: #172033;
}
.sidebar-notifications-header span,
.sidebar-notifications-empty,
.sidebar-notification-row span,
.sidebar-notification-row small {
@apply text-xs leading-5;
color: #526178;
}
.sidebar-notifications-empty {
@apply px-3 py-4;
}
.sidebar-notification-row {
@apply flex flex-col gap-1 rounded-[0.9rem] px-3 py-3 text-left transition-colors;
}
.sidebar-notification-row:hover {
background: rgba(23, 32, 51, 0.06);
}
.sidebar-notification-row-unread {
background: rgba(15, 118, 110, 0.08);
}
.sidebar-notification-row strong {
@apply text-sm font-semibold;
color: #172033;
}
.sidebar-section {
@apply flex flex-col gap-2;
}
.sidebar-section-header {
@apply flex items-center gap-2;
}
.sidebar-link {
@apply flex min-w-0 items-center gap-3 rounded-[1.1rem] px-4 py-3 text-sm font-semibold no-underline transition-colors;
color: #44516a;
}
.sidebar-link:hover {
background: rgba(23, 32, 51, 0.06);
color: #172033;
}
.sidebar-link-active {
background: linear-gradient(135deg, rgba(255, 138, 61, 0.14), rgba(239, 68, 68, 0.1));
color: #172033;
box-shadow: inset 0 0 0 1px rgba(255, 138, 61, 0.2);
}
.sidebar-link-section {
@apply flex-1;
}
.sidebar-link-main {
@apply flex min-w-0 items-center gap-3;
}
.sidebar-link-label {
@apply truncate;
}
.sidebar-section-action {
@apply ml-auto flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-[1rem] transition-colors no-underline;
color: #526178;
}
.sidebar-section-action:hover {
background: rgba(23, 32, 51, 0.06);
color: #172033;
}
.sidebar-chevron {
@apply text-base transition-transform;
}
.sidebar-chevron-open {
transform: rotate(180deg);
}
.sidebar-link :deep(.v-icon),
.sidebar-section-action :deep(.v-icon) {
@apply h-5 w-5 flex-shrink-0 text-xl;
}
.sidebar-sublist {
@apply flex flex-col gap-1 pl-4;
}
.sidebar-sublink {
@apply flex flex-col rounded-[1rem] px-4 py-3 text-sm no-underline transition-colors;
color: #526178;
}
.sidebar-sublink:hover,
.sidebar-sublink-active {
background: rgba(23, 32, 51, 0.05);
color: #172033;
}
.sidebar-sublink strong {
@apply font-semibold;
}
.sidebar-sublink small,
.sidebar-empty {
@apply text-xs;
color: #7a8799;
}
.app-sidebar-collapsed {
@apply w-[5.5rem] px-3;
}
.app-sidebar-collapsed .brand-block {
@apply justify-center;
}
.app-sidebar-collapsed .sidebar-search {
@apply justify-center px-0;
}
.app-sidebar-collapsed .sidebar-link {
@apply justify-center px-0;
}
.app-sidebar-collapsed .sidebar-link-main {
@apply justify-center;
}
.app-sidebar-collapsed .sidebar-search:hover {
background: rgba(23, 32, 51, 0.07);
}
.app-sidebar-collapsed .sidebar-search-panel-input {
@apply justify-start px-4;
}
.app-sidebar-collapsed .sidebar-floating-panel {
left: calc(100% + 0.75rem);
right: auto;
width: min(22rem, calc(100vw - 7rem));
}
</style>