refactor: organize frontend by feature
Some checks failed
Backend CI/CD / build_and_deploy (push) Has been cancelled
Frontend CI/CD / build_and_deploy (push) Has been cancelled

This commit is contained in:
2026-04-25 01:05:50 -04:00
parent b6eb692c27
commit 121757546a
60 changed files with 107 additions and 183 deletions

View File

@@ -0,0 +1,895 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import AppAvatar from '@/components/AppAvatar.vue';
import { useAuthStore } from '@/features/auth/stores/authStore.js';
import { useChannelsStore } from '@/features/channels/stores/channelsStore.js';
import { useLanguageStore } from '@/stores/languageStore.js';
import { useNotificationsStore } from '@/features/notifications/stores/notificationsStore.js';
import { useContentItemsStore } from '@/features/content/stores/contentItemsStore.js';
import { useProjectsStore } from '@/features/projects/stores/projectsStore.js';
import { useUserProfileStore } from '@/features/user-profile/stores/userProfileStore.js';
import {
mdiBellOutline,
mdiCalendarMonthOutline,
mdiChevronDown,
mdiCogOutline,
mdiFolderOutline,
mdiHomeOutline,
mdiImageMultipleOutline,
mdiLan,
mdiMagnify,
mdiPlus,
} 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 languageStore = useLanguageStore();
const notificationsStore = useNotificationsStore();
const projectsStore = useProjectsStore();
const userProfileStore = useUserProfileStore();
const isUserMenuOpen = ref(false);
const isNotificationsOpen = ref(false);
const isSearchFocused = ref(false);
const searchQuery = ref('');
const userMenuRef = ref(null);
const notificationsRef = ref(null);
const searchRef = ref(null);
const primaryLinks = [
{ to: '/app/dashboard', labelKey: 'nav.overview', icon: mdiHomeOutline },
{ to: '/app/workspace', labelKey: 'nav.workspacePlan', icon: mdiCalendarMonthOutline },
{ to: '/app/media-library', labelKey: 'nav.mediaLibrary', icon: mdiImageMultipleOutline },
{ to: '/app/workspace-settings', labelKey: 'nav.settings', icon: mdiCogOutline },
];
const openSections = ref({
channels: false,
projects: false,
});
const normalizedSearchQuery = computed(() => searchQuery.value.trim().toLowerCase());
const projectResults = computed(() => {
if (!normalizedSearchQuery.value) {
return [];
}
return projectsStore.projects
.filter(project => project.name.toLowerCase().includes(normalizedSearchQuery.value))
.slice(0, 5)
.map(project => ({
id: project.id,
label: project.name,
description: 'Campaign',
route: { name: 'campaign-detail', params: { projectId: project.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(() =>
projectResults.value.length > 0 || contentResults.value.length > 0
);
const isSearchOpen = computed(() => isSearchFocused.value && 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'),
}));
function toggleSection(sectionName) {
openSections.value[sectionName] = !openSections.value[sectionName];
}
function toggleNotifications() {
isNotificationsOpen.value = !isNotificationsOpen.value;
}
function toggleUserMenu() {
if (!props.isExpanded) {
return;
}
isUserMenuOpen.value = !isUserMenuOpen.value;
}
function toggleLanguage() {
const nextLocale = languageStore.locale === 'en' ? 'fr' : 'en';
languageStore.setLocale(nextLocale);
isUserMenuOpen.value = false;
}
async function openProfile() {
isUserMenuOpen.value = false;
await router.push({ name: 'settings-user-information' });
}
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;
if (notification.contentItemId) {
await router.push({ name: 'content-item-detail', params: { id: notification.contentItemId } });
}
}
async function openSearchResult(result) {
isSearchFocused.value = false;
await router.push(result.route);
}
function handleLogout() {
isUserMenuOpen.value = false;
authStore.logout();
}
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;
}
if (isUserMenuOpen.value && userMenuRef.value && !userMenuRef.value.contains(event.target)) {
isUserMenuOpen.value = false;
}
}
watch(
() => route.path,
path => {
if (path.startsWith('/app/channels')) {
openSections.value.channels = true;
}
if (path.startsWith('/app/campaigns')) {
openSections.value.projects = true;
}
},
{ immediate: true }
);
watch(
() => props.isExpanded,
isExpanded => {
if (!isExpanded) {
isUserMenuOpen.value = false;
}
}
);
onMounted(() => {
document.addEventListener('click', handleDocumentClick);
});
onBeforeUnmount(() => {
document.removeEventListener('click', handleDocumentClick);
});
</script>
<template>
<aside
class="app-sidebar"
:class="{ 'app-sidebar-collapsed': !isExpanded }"
>
<div class="app-sidebar-inner">
<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 }"
>
<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="isSearchOpen"
class="sidebar-floating-panel"
>
<div
v-if="projectResults.length"
class="sidebar-search-group"
>
<strong>Campaigns</strong>
<button
v-for="result in projectResults"
:key="`project-${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="!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 primaryLinks"
: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/campaigns"
class="sidebar-link sidebar-link-section"
active-class="sidebar-link-active"
:title="!isExpanded ? t('nav.projects') : null"
>
<span class="sidebar-link-main">
<v-icon :icon="mdiFolderOutline" />
<span
v-if="isExpanded"
class="sidebar-link-label"
>
{{ t('nav.projects') }}
</span>
</span>
</router-link>
<router-link
v-if="isExpanded"
to="/app/campaigns?create=true"
class="sidebar-section-action"
:title="t('projects.createTitle')"
>
<v-icon :icon="mdiPlus" />
</router-link>
<button
v-if="isExpanded"
class="sidebar-section-toggle"
type="button"
@click="toggleSection('projects')"
>
<v-icon
:icon="mdiChevronDown"
class="sidebar-chevron"
:class="{ 'sidebar-chevron-open': openSections.projects }"
/>
</button>
</div>
<div
v-if="isExpanded && openSections.projects"
class="sidebar-sublist"
>
<router-link
to="/app/campaigns"
class="sidebar-sublink sidebar-sublink-overview"
active-class="sidebar-sublink-active"
>
<span>{{ t('sidebar.allProjects') }}</span>
</router-link>
<router-link
v-for="project in projectsStore.projects"
:key="project.id"
:to="{ name: 'campaign-detail', params: { projectId: project.id } }"
class="sidebar-sublink"
active-class="sidebar-sublink-active"
>
<span>{{ project.name }}</span>
</router-link>
<div
v-if="!projectsStore.projects.length"
class="sidebar-empty"
>
{{ t('sidebar.noProjects') }}
</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"
>
<span class="sidebar-link-main">
<v-icon :icon="mdiLan" />
<span
v-if="isExpanded"
class="sidebar-link-label"
>
{{ t('nav.channels') }}
</span>
</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>
<button
v-if="isExpanded"
class="sidebar-section-toggle"
type="button"
@click="toggleSection('channels')"
>
<v-icon
:icon="mdiChevronDown"
class="sidebar-chevron"
:class="{ 'sidebar-chevron-open': openSections.channels }"
/>
</button>
</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
v-if="authStore.isAuthenticated"
ref="userMenuRef"
class="sidebar-workspace sidebar-workspace-bottom"
>
<button
class="sidebar-workspace-trigger"
type="button"
:title="!isExpanded ? userProfileStore.alias : null"
@click.stop="toggleUserMenu"
>
<AppAvatar
:name="userProfileStore.alias"
:src="userProfileStore.portraitUrl"
size="sm"
/>
<span
v-if="isExpanded"
class="sidebar-workspace-label"
>
{{ userProfileStore.alias }}
</span>
<v-icon
v-if="isExpanded"
:icon="mdiChevronDown"
class="sidebar-workspace-icon"
:class="{ 'sidebar-workspace-icon-open': isUserMenuOpen }"
/>
</button>
<div
v-if="isExpanded && isUserMenuOpen"
class="sidebar-workspace-menu"
>
<button
class="sidebar-workspace-option"
type="button"
@click="openProfile"
>
{{ t('nav.profile') }}
</button>
<button
class="sidebar-workspace-option"
type="button"
@click="toggleLanguage"
>
{{ t('nav.language') }}
</button>
<button
class="sidebar-workspace-option sidebar-workspace-option-danger"
type="button"
@click="handleLogout"
>
{{ t('nav.signOut') }}
</button>
</div>
</div>
</div>
</aside>
</template>
<style scoped>
.app-sidebar {
@apply w-[19rem] flex-shrink-0 px-4 pb-4 pt-4 transition-[width,padding] duration-200 md:sticky md:top-24 md:h-[calc(100vh-6rem)] md:pt-0;
border-right: 1px solid rgba(23, 32, 51, 0.08);
}
.app-sidebar-inner {
@apply flex h-full flex-col gap-4 overflow-y-auto py-3 pr-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 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-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-workspace {
@apply relative flex flex-col gap-2;
}
.sidebar-workspace-bottom {
@apply mt-auto pt-4;
border-top: 1px solid rgba(23, 32, 51, 0.08);
}
.sidebar-workspace-kicker {
@apply px-4 text-[10px] font-bold uppercase tracking-[0.22em];
color: #7a8799;
}
.sidebar-workspace-trigger {
@apply flex w-full items-center gap-3 rounded-[1.1rem] px-4 py-3 text-left transition-colors;
background: rgba(23, 32, 51, 0.04);
color: #172033;
}
.sidebar-workspace-trigger:hover {
background: rgba(23, 32, 51, 0.07);
}
.sidebar-workspace-label {
@apply flex-1 truncate text-sm font-semibold;
}
.sidebar-workspace-icon {
@apply text-base transition-transform;
color: #5d6b82;
}
.sidebar-workspace-icon-open {
transform: rotate(180deg);
}
.sidebar-workspace-menu {
@apply absolute bottom-[calc(100%+0.5rem)] left-0 right-0 z-30 flex flex-col gap-1 rounded-[1.25rem] border p-2;
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-workspace-option {
@apply rounded-[0.95rem] px-4 py-3 text-left text-sm font-semibold transition-colors;
color: #172033;
}
.sidebar-workspace-option:hover {
background: rgba(23, 32, 51, 0.05);
}
.sidebar-workspace-option-danger {
color: #b91c1c;
}
.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-toggle {
@apply flex h-11 w-11 items-center justify-center rounded-[1rem] transition-colors;
color: #526178;
}
.sidebar-section-action {
@apply flex h-11 w-11 items-center justify-center rounded-[1rem] transition-colors no-underline;
color: #526178;
}
.sidebar-section-action:hover,
.sidebar-section-toggle: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) {
@apply 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 .sidebar-search {
@apply justify-center px-0;
}
.app-sidebar-collapsed .sidebar-floating-panel {
left: calc(100% + 0.75rem);
right: auto;
width: min(22rem, calc(100vw - 7rem));
}
</style>