This commit is contained in:
gsinghpal
2026-03-13 12:38:28 -04:00
parent db4b9aa278
commit fc3c966484
2975 changed files with 1614 additions and 498 deletions

View File

@@ -0,0 +1,26 @@
// = App Switcher Dark Mode
// ============================================================================
.o_fusion_app_switcher.dropdown-menu {
--o-fusion-appsmenu-bg-color: #000511;
--o-fusion-app-icon-bg: rgba(255, 255, 255, 0.95);
--o-fusion-app-icon-inset-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.1);
--o-fusion-appsmenu-caption-color: #E4E4E4;
scrollbar-color: rgba(255, 255, 255, .3) transparent;
&::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, .3);
}
.o_app > a .o_fusion_app_name {
text-shadow: 0 1px 2px rgba(0, 0, 0, .75),
0 2px 5px rgba(0, 0, 0, .05),
0 0 5px rgba(0, 0, 0, .05);
}
.o_app > a.o_fusion_app_focused {
background: rgba(255, 255, 255, 0.08);
outline: 1px solid rgba(255, 255, 255, 0.2);
}
}

View File

@@ -0,0 +1,210 @@
/** @odoo-module **/
import { useEffect, useRef } from "@odoo/owl";
import { user } from "@web/core/user";
import { url } from "@web/core/utils/urls";
import { useBus, useService } from "@web/core/utils/hooks";
import { useSortable } from "@web/core/utils/sortable_owl";
import { useHotkey } from "@web/core/hotkeys/hotkey_hook";
import { Dropdown } from "@web/core/dropdown/dropdown";
export class AppSwitcher extends Dropdown {
setup() {
super.setup();
this.commandPaletteOpen = false;
this.commandService = useService("command");
this.ui = useService("ui");
this.sortableRef = useRef("sortableArea");
this.state.focusedIndex = null;
if (user.activeCompany.has_background_image) {
this.imageUrl = url('/web/image', {
model: 'res.company',
field: 'background_image',
id: user.activeCompany.id,
});
} else {
this.imageUrl = '/fusion_backend_theme/static/src/img/background.png';
}
// Drag-and-drop reordering
useSortable({
enable: () => this.state.isOpen,
ref: this.sortableRef,
elements: ".o_app",
cursor: "move",
delay: 500,
tolerance: 10,
onDrop: (params) => this._onSortDrop(params),
});
useEffect(
(isOpen) => {
if (isOpen) {
this.state.focusedIndex = null;
const openMainPalette = (ev) => {
if (
!this.commandPaletteOpen &&
ev.key.length === 1 &&
!ev.ctrlKey &&
!ev.altKey
) {
this.commandService.openMainPalette(
{ searchValue: `/${ev.key}` },
() => { this.commandPaletteOpen = false; }
);
this.commandPaletteOpen = true;
}
};
window.addEventListener("keydown", openMainPalette);
return () => {
window.removeEventListener("keydown", openMainPalette);
this.commandPaletteOpen = false;
};
}
},
() => [this.state.isOpen]
);
useBus(this.env.bus, "ACTION_MANAGER:UI-UPDATED", () => {
if (this.state.close) {
this.state.close();
}
});
// Keyboard navigation
this._registerHotkeys();
}
get maxIconNumber() {
const w = window.innerWidth;
if (w < 576) {
return 3;
} else if (w < 768) {
return 4;
} else {
return 6;
}
}
_registerHotkeys() {
const hotkeys = [
["ArrowDown", () => this._updateFocusedIndex("nextLine")],
["ArrowRight", () => this._updateFocusedIndex("nextColumn")],
["ArrowUp", () => this._updateFocusedIndex("previousLine")],
["ArrowLeft", () => this._updateFocusedIndex("previousColumn")],
["Tab", () => this._updateFocusedIndex("nextElem")],
["shift+Tab", () => this._updateFocusedIndex("previousElem")],
["Escape", () => {
if (this.state.close) {
this.state.close();
}
}],
];
for (const [hotkey, callback] of hotkeys) {
useHotkey(hotkey, callback, { allowRepeat: true });
}
}
_updateFocusedIndex(cmd) {
if (!this.state.isOpen) {
return;
}
const apps = this._getAppElements();
const nbrApps = apps.length;
const lastIndex = nbrApps - 1;
const focusedIndex = this.state.focusedIndex;
if (lastIndex < 0) {
return;
}
if (focusedIndex === null) {
this.state.focusedIndex = 0;
return;
}
const lineNumber = Math.ceil(nbrApps / this.maxIconNumber);
const currentLine = Math.ceil((focusedIndex + 1) / this.maxIconNumber);
let newIndex;
switch (cmd) {
case "previousElem":
newIndex = focusedIndex - 1;
break;
case "nextElem":
newIndex = focusedIndex + 1;
break;
case "previousColumn":
if (focusedIndex % this.maxIconNumber) {
newIndex = focusedIndex - 1;
} else {
newIndex = focusedIndex + Math.min(lastIndex - focusedIndex, this.maxIconNumber - 1);
}
break;
case "nextColumn":
if (focusedIndex === lastIndex || (focusedIndex + 1) % this.maxIconNumber === 0) {
newIndex = (currentLine - 1) * this.maxIconNumber;
} else {
newIndex = focusedIndex + 1;
}
break;
case "previousLine":
if (currentLine === 1) {
newIndex = focusedIndex + (lineNumber - 1) * this.maxIconNumber;
if (newIndex > lastIndex) {
newIndex = lastIndex;
}
} else {
newIndex = focusedIndex - this.maxIconNumber;
}
break;
case "nextLine":
if (currentLine === lineNumber) {
newIndex = focusedIndex % this.maxIconNumber;
} else {
newIndex = focusedIndex + Math.min(this.maxIconNumber, lastIndex - focusedIndex);
}
break;
}
if (newIndex < 0) {
newIndex = lastIndex;
} else if (newIndex > lastIndex) {
newIndex = 0;
}
this.state.focusedIndex = newIndex;
}
_getAppElements() {
if (this.menuRef && this.menuRef.el) {
return [...this.menuRef.el.querySelectorAll('.o_app')];
}
return [];
}
_onSortDrop({ element, previous }) {
const apps = this._getAppElements();
const order = apps.map((el) => {
const link = el.querySelector('a[data-menu-xmlid]') || el;
return link.dataset.menuXmlid;
}).filter(Boolean);
const elementLink = element.querySelector('a[data-menu-xmlid]') || element;
const elementId = elementLink.dataset.menuXmlid;
const elementIndex = order.indexOf(elementId);
if (elementIndex === -1) {
return;
}
order.splice(elementIndex, 1);
if (previous) {
const prevLink = previous.querySelector('a[data-menu-xmlid]') || previous;
const prevIndex = order.indexOf(prevLink.dataset.menuXmlid);
order.splice(prevIndex + 1, 0, elementId);
} else {
order.splice(0, 0, elementId);
}
user.setUserSettings("homemenu_config", JSON.stringify(order));
}
onOpened() {
super.onOpened();
if (this.menuRef && this.menuRef.el) {
this.menuRef.el.style.backgroundImage = `url('${this.imageUrl}')`;
}
}
}

View File

@@ -0,0 +1,127 @@
.o_navbar_apps_menu .dropdown-toggle {
padding: 0px 14px !important;
}
.o_fusion_app_switcher.dropdown-menu {
display: flex !important;
flex-direction: row !important;
flex-wrap: wrap !important;
align-content: flex-start;
right: 0 !important;
left: 0 !important;
bottom: 0 !important;
max-height: 100vh;
overflow-x: hidden;
overflow-y: auto;
border: none;
border-radius: 0;
user-select: none;
margin-top: 0 !important;
margin-bottom: 0 !important;
background: {
size: cover;
repeat: no-repeat;
position: center;
color: var(--o-fusion-appsmenu-bg-color, #{$o-gray-200});
}
scrollbar-color: rgba(255, 255, 255, .4) transparent;
&::-webkit-scrollbar {
width: 8px;
}
&::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, .4);
border-radius: 10px;
}
@include media-breakpoint-up(lg) {
padding: {
left: 20vw;
right: 20vw;
}
}
.o_fusion_apps_grid {
padding-top: 20px;
}
.o_app {
margin-top: 20px;
width: percentage(1/3);
background: none !important;
@include media-breakpoint-up(sm) {
width: percentage(1/4);
}
@include media-breakpoint-up(md) {
width: percentage(1/6);
}
> a {
display: flex;
align-items: center;
flex-direction: column;
border-radius: 0.375rem;
padding: 8px;
transition: background-color 0.15s ease;
.o_fusion_app_icon {
width: 100%;
padding: 10px;
max-width: 70px;
aspect-ratio: 1;
border-radius: 0.375rem;
background-color: var(--o-fusion-app-icon-bg, rgba(255, 255, 255, 1));
object-fit: cover;
transform-origin: center bottom;
transition: box-shadow ease-in 0.1s, transform ease-in 0.1s;
box-shadow:
var(--o-fusion-app-icon-inset-shadow, inset 0 0 0 1px rgba(0, 0, 0, 0.2)),
0 1px 1px rgba(0, 0, 0, 0.02),
0 2px 2px rgba(0, 0, 0, 0.02),
0 4px 4px rgba(0, 0, 0, 0.02),
0 8px 8px rgba(0, 0, 0, 0.02);
}
.o_fusion_app_name {
color: var(--o-fusion-appsmenu-caption-color, #{$o-fusion-color-appsmenu-text});
text-align: center;
margin-top: 8px;
font-size: 0.875rem;
font-weight: 500;
}
&.o_fusion_app_focused {
background: rgba(255, 255, 255, 0.1);
outline: 1px solid rgba(255, 255, 255, 0.3);
}
}
&:hover {
> a .o_fusion_app_icon {
box-shadow:
var(--o-fusion-app-icon-inset-shadow, inset 0 0 0 1px rgba(0, 0, 0, 0.2)),
0 2px 2px rgba(0, 0, 0, 0.03),
0 4px 4px rgba(0, 0, 0, 0.03),
0 8px 8px rgba(0, 0, 0, 0.03),
0 12px 12px rgba(0, 0, 0, 0.03),
0 24px 24px rgba(0, 0, 0, 0.03);
transform: translateY(-2px);
}
}
&:active {
> a .o_fusion_app_icon {
transform: translateY(-2px) scale(.98);
transition: none;
}
}
}
// Drag-and-drop visual feedback
.o_dragged_app,
.o_app.o_dragging {
transition: transform 0.5s;
transform: rotate(6deg);
> a .o_fusion_app_icon {
box-shadow: 0 8px 15px -10px black;
transform: translateY(-1px);
}
}
}

View File

@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<!-- Replace the apps menu dropdown with app switcher -->
<t
t-name="fusion_backend_theme.NavBar.AppSwitcher"
t-inherit="web.NavBar.AppsMenu"
t-inherit-mode="extension"
>
<xpath expr="//Dropdown" position="replace">
<AppSwitcher menuClass="'o_fusion_app_switcher'">
<button data-hotkey="h" title="Home Menu">
<i class="oi oi-apps" />
</button>
<t t-set-slot="content">
<div t-ref="sortableArea" class="o_fusion_apps_grid d-flex flex-wrap align-content-start w-100">
<DropdownItem
t-foreach="this.appMenuService.getAppsMenuItems()"
t-as="app"
t-key="app.id"
class="'o_app o_draggable'"
attrs="{ href: app.href, 'data-menu-xmlid': app.xmlid, 'data-section': app.id }"
onSelected="() => this.onNavBarDropdownItemSelection(app)"
closingMode="'none'"
>
<a
t-att-href="app.href"
t-on-click.prevent=""
t-att-class="{ 'o_fusion_app_focused': state.focusedIndex === app_index }"
>
<img
t-if="app.webIconData"
class="o_fusion_app_icon"
t-att-src="app.webIconData"
/>
<img
t-else=""
class="o_fusion_app_icon"
src="/base/static/description/icon.png"
/>
<span class="o_fusion_app_name">
<t t-out="app.label"/>
</span>
</a>
</DropdownItem>
</div>
</t>
</AppSwitcher>
</xpath>
</t>
</templates>

View File

@@ -0,0 +1,11 @@
// = Navbar Dark Mode
// ============================================================================
.o_main_navbar {
--o-fusion-navbar-active-color: #E4E4E4;
--o-fusion-navbar-active-border: #5D8DA8;
--o-fusion-navbar-active-bg: rgba(93, 141, 168, 0.15);
--o-fusion-navbar-hover-bg: #3C3E4B;
--o-fusion-navbar-focus-bg: #3C3E4B;
--o-fusion-navbar-toggle-color: #5D8DA8;
}

View File

@@ -0,0 +1,23 @@
/** @odoo-module **/
import { patch } from '@web/core/utils/patch';
import { useService } from '@web/core/utils/hooks';
import { useRef } from '@odoo/owl';
import { NavBar } from '@web/webclient/navbar/navbar';
import { AppSwitcher } from "@fusion_backend_theme/webclient/app_switcher/app_switcher";
patch(NavBar.prototype, {
setup() {
super.setup();
this.appMenuService = useService('app_menu');
this.navRef = useRef("nav");
},
});
patch(NavBar, {
components: {
...NavBar.components,
AppSwitcher,
},
});

View File

@@ -0,0 +1,37 @@
// = Main Navbar
// ============================================================================
.o_main_navbar {
border-bottom: none !important;
position: relative;
z-index: 2;
// Ensure consistent navbar entry colors for dark brand background
--NavBar-entry-color: rgba(255, 255, 255, 0.9);
--NavBar-entry-color--active: #{$o-white};
--NavBar-entry-borderColor-active: #{$o-fusion-color-primary};
--NavBar-entry-backgroundColor: #{$o-fusion-color-brand};
--NavBar-entry-backgroundColor--active: #{darken($o-fusion-color-brand, 5%)};
--NavBar-entry-backgroundColor--hover: #{lighten($o-fusion-color-brand, 8%)};
--NavBar-entry-backgroundColor--focus: #{lighten($o-fusion-color-brand, 8%)};
.o_menu_toggle {
color: var(--NavBar-entry-color, rgba(255, 255, 255, 0.9));
}
.o_menu_brand {
transition: opacity 0.15s ease;
}
.o_menu_systray .badge {
font-size: 0.65em;
}
}
// Ensure Discuss keeps the dark navbar consistent with all other apps
.o_web_client:has(.o-mail-Discuss) {
.o_main_navbar {
background: $o-fusion-color-brand !important;
--NavBar-entry-backgroundColor: #{$o-fusion-color-brand};
}
}

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<!-- Extend the NavBar to add the nav ref -->
<t
t-name="fusion_backend_theme.NavBar"
t-inherit="web.NavBar"
t-inherit-mode="extension"
>
<xpath expr="//nav" position="attributes">
<attribute name="t-ref">nav</attribute>
</xpath>
</t>
</templates>

View File

@@ -0,0 +1,33 @@
import { registry } from "@web/core/registry";
import { user } from "@web/core/user";
import { computeAppsAndMenuItems, reorderApps } from "@web/webclient/menus/menu_helpers";
export const appMenuService = {
dependencies: ["menu"],
async start(env, { menu }) {
return {
getCurrentApp () {
return menu.getCurrentApp();
},
getAppsMenuItems() {
const menuItems = computeAppsAndMenuItems(
menu.getMenuAsTree('root')
)
const apps = menuItems.apps;
const menuConfig = JSON.parse(
user.settings?.homemenu_config || 'null'
);
if (menuConfig) {
reorderApps(apps, menuConfig);
}
return apps;
},
selectApp(app) {
menu.selectMenu(app);
}
};
},
};
registry.category("services").add("app_menu", appMenuService);

View File

@@ -0,0 +1,6 @@
// = Sidebar Dark Mode
// ============================================================================
// Dark mode sidebar colors ($o-fusion-color-sidebar-text, $o-fusion-color-sidebar-background)
// are defined in primary_variables.dark.scss and automatically applied
// to the sidebar component styles.

View File

@@ -0,0 +1,34 @@
import { url } from '@web/core/utils/urls';
import { useService } from '@web/core/utils/hooks';
import { user } from "@web/core/user";
import { Component, onWillUnmount } from '@odoo/owl';
export class Sidebar extends Component {
static template = 'fusion_backend_theme.Sidebar';
static props = {};
setup() {
this.appMenuService = useService('app_menu');
if (user.activeCompany.has_appsbar_image) {
this.sidebarImageUrl = url('/web/image', {
model: 'res.company',
field: 'appbar_image',
id: user.activeCompany.id,
});
}
const renderAfterMenuChange = () => {
this.render();
};
this.env.bus.addEventListener(
'MENUS:APP-CHANGED', renderAfterMenuChange
);
onWillUnmount(() => {
this.env.bus.removeEventListener(
'MENUS:APP-CHANGED', renderAfterMenuChange
);
});
}
_onAppClick(app) {
return this.appMenuService.selectApp(app);
}
}

View File

@@ -0,0 +1,95 @@
.o_fusion_sidebar_panel {
@include o-fusion-disable-scrollbar();
background-color: $o-fusion-color-sidebar-background;
width: var(--o-fusion-sidebar-width, 0);
overflow-y: auto;
.o_fusion_sidebar {
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
white-space: nowrap;
.o_fusion_sidebar_menu {
padding: 0;
> li > a {
cursor: pointer;
font-size: 13px;
font-weight: 300;
overflow: hidden;
padding: 8px 11px;
text-decoration: none;
color: $o-fusion-color-sidebar-text;
text-overflow: ellipsis;
.o_fusion_sidebar_icon {
width: 22px;
height: 22px;
margin-right: 5px;
}
}
> li.active > a {
background: $o-fusion-color-sidebar-active;
}
> li:hover > a {
background: $o-fusion-color-sidebar-active;
}
}
}
}
.o_fusion_sidebar_type_large {
--o-fusion-sidebar-width: #{$o-fusion-sidebar-large-width};
}
.o_fusion_sidebar_type_small {
--o-fusion-sidebar-width: #{$o-fusion-sidebar-small-width};
.o_fusion_sidebar_name {
display: none;
}
.o_fusion_sidebar_icon {
margin-right: 0 !important;
}
.o_fusion_sidebar_logo {
display: none;
}
}
.o_fusion_sidebar_type_invisible {
--o-fusion-sidebar-width: 0;
}
.editor_has_snippets_hide_backend_navbar,
.o_home_menu_background,
.o_fullscreen {
--o-fusion-sidebar-width: 0;
}
.editor_has_snippets_hide_backend_navbar .o_fusion_sidebar_panel {
transition: width 300ms;
}
@include media-breakpoint-only(md) {
.o_fusion_sidebar_type_large {
--o-fusion-sidebar-width: #{$o-fusion-sidebar-small-width};
.o_fusion_sidebar_name {
display: none;
}
.o_fusion_sidebar_icon {
margin-right: 0 !important;
}
.o_fusion_sidebar_logo {
display: none;
}
}
}
@include media-breakpoint-down(md) {
.o_fusion_sidebar_type_large, .o_fusion_sidebar_type_small {
--o-fusion-sidebar-width: 0;
}
}
@media print {
.o_fusion_sidebar_type_large, .o_fusion_sidebar_type_small {
--o-fusion-sidebar-width: 0;
}
}

View File

@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="fusion_backend_theme.Sidebar">
<div class="o_fusion_sidebar_panel">
<div class="o_fusion_sidebar">
<ul class="o_fusion_sidebar_menu">
<t t-foreach="this.appMenuService.getAppsMenuItems()" t-as="app" t-key="app.id">
<li t-attf-class="nav-item {{ app.id === this.appMenuService.getCurrentApp()?.id ? 'active' : '' }}">
<a
t-att-href="app.href"
t-att-data-menu-id="app.id"
t-att-data-menu-xmlid="app.xmlid"
t-att-data-action-id="app.actionID"
t-on-click.prevent="() => this._onAppClick(app)"
class="nav-link"
role="menuitem"
>
<img
t-if="app.webIconData"
class="o_fusion_sidebar_icon"
t-att-src="app.webIconData"
/>
<img
t-else=""
class="o_fusion_sidebar_icon"
src="/base/static/description/icon.png"
/>
<span class="o_fusion_sidebar_name">
<t t-out="app.label"/>
</span>
</a>
</li>
</t>
</ul>
<div t-if="sidebarImageUrl" class="o_fusion_sidebar_logo p-2">
<img class="img-fluid mx-auto" t-att-src="sidebarImageUrl" alt="Logo"/>
</div>
</div>
</div>
</t>
</templates>

View File

@@ -0,0 +1,11 @@
import { patch } from '@web/core/utils/patch';
import { WebClient } from '@web/webclient/webclient';
import { Sidebar } from '@fusion_backend_theme/webclient/sidebar/sidebar';
patch(WebClient, {
components: {
...WebClient.components,
Sidebar,
},
});

View File

@@ -0,0 +1,29 @@
.o_web_client {
display: grid !important;
grid-template-areas:
"banner banner"
"navbar navbar"
"sidebar content"
"components components";
grid-template-rows: auto auto 1fr auto;
grid-template-columns: auto 1fr;
> div:has(#oe_neutralize_banner) {
grid-area: banner;
}
> .o_navbar {
grid-area: navbar;
}
> .o_fusion_sidebar_panel {
grid-area: sidebar;
}
> .o_action_manager {
grid-area: content;
}
> .o-main-components-container {
grid-area: components;
}
> iframe {
grid-column: 1 / -1;
width: 100%;
}
}

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t
t-name="fusion_backend_theme.WebClient"
t-inherit="web.WebClient"
t-inherit-mode="extension"
>
<xpath expr="//NavBar" position="after">
<Sidebar/>
</xpath>
</t>
</templates>