mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-20 10:01:00 +00:00
Merge branch 'feat/new-side-nav'
* feat/new-side-nav: (61 commits) feat: make scrollbars a bit more subtle feat(sideNav): move drag handle right of scrollbars feat(sideNav): make text color work better feat(sideNav): make shepherd kinda work and throw out more layout stuff feat(sideNav): remove side nav layout store stuff feat(sideNav): make mobile nav work again feat(sideNav): remove grab cursor from Today and Inbox entries feat(sideNav): add escape key to unfocus nav and focus first task feat(sideNav): fix colors and make focus shortcut working feat(sideNav): prepare focus sidenav feat(sideNav): make backdrop work feat(sideNav): always close mobile nav when item was clicked feat(sideNav): improve mobile feat(sideNav): make mobile nav work feat(sideNav): move certain nav parts to the bottom feat(sideNav): bring back project visibility menu feat(sideNav): improve styling refactor(sideNav): remove logs feat(sideNav): make icons work again feat(sideNav): make keyboard nav work ... # Conflicts: # src/app/core-ui/side-nav/side-nav-item/side-nav-item.component.scss # src/app/core-ui/side-nav/side-nav.component.scss # src/styles/_css-variables.scss
This commit is contained in:
commit
4f0154e19e
55 changed files with 2799 additions and 1268 deletions
|
|
@ -1,3 +1,3 @@
|
|||
export const cssSelectors = {
|
||||
SIDENAV: 'side-nav',
|
||||
SIDENAV: 'magic-side-nav',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -25,18 +25,13 @@
|
|||
@if (isShowFocusOverlay()) {
|
||||
<focus-mode-overlay @warp></focus-mode-overlay>
|
||||
} @else {
|
||||
<mat-sidenav-container [dir]="isRTL ? 'rtl' : 'ltr'">
|
||||
<mat-sidenav
|
||||
(closedStart)="layoutService.hideSideNav()"
|
||||
[mode]="layoutService.isNavOver() ? 'over' : 'side'"
|
||||
[opened]="layoutService.isShowSideNav()"
|
||||
[disableClose]="layoutService.isNavAlwaysVisible()"
|
||||
position="start"
|
||||
>
|
||||
<side-nav></side-nav>
|
||||
@if (workContextService.isActiveWorkContextProject$ | async) {}
|
||||
</mat-sidenav>
|
||||
<mat-sidenav-content>
|
||||
<div
|
||||
class="app-container"
|
||||
[dir]="isRTL ? 'rtl' : 'ltr'"
|
||||
>
|
||||
<magic-side-nav [activeWorkContextId]="getActiveWorkContextId()"> </magic-side-nav>
|
||||
|
||||
<main class="main-content">
|
||||
<header
|
||||
[class.isNotScrolled]="!layoutService.isScrolled()"
|
||||
class="header-wrapper"
|
||||
|
|
@ -56,8 +51,8 @@
|
|||
</div>
|
||||
</right-panel>
|
||||
<global-progress-bar></global-progress-bar>
|
||||
</mat-sidenav-content>
|
||||
</mat-sidenav-container>
|
||||
</main>
|
||||
</div>
|
||||
}
|
||||
<!-- -->
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,38 @@
|
|||
height: 100%;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
min-height: 100vh;
|
||||
// mobile viewport bug fix
|
||||
min-height: -webkit-fill-available;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0; // Prevent flex item from growing beyond container
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// Override magic-side-nav styles for flex layout integration
|
||||
// Use desktop layout overrides only on larger screens
|
||||
@media (min-width: 768px) {
|
||||
magic-side-nav {
|
||||
::ng-deep .nav-sidebar {
|
||||
position: relative !important;
|
||||
top: auto !important;
|
||||
left: auto !important;
|
||||
bottom: auto !important;
|
||||
height: 100vh !important;
|
||||
min-height: -webkit-fill-available !important;
|
||||
z-index: auto !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header-wrapper {
|
||||
position: relative;
|
||||
z-index: var(--z-main-header-wrapper);
|
||||
|
|
@ -96,7 +128,7 @@ mat-sidenav-container {
|
|||
mat-sidenav ::ng-deep .mat-drawer-inner-container {
|
||||
@include scrollYImportant();
|
||||
|
||||
background: var(--sidebar-bg);
|
||||
background: var(--sidenav-bg);
|
||||
}
|
||||
|
||||
mat-sidenav {
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import { GlobalConfigService } from './features/config/global-config.service';
|
|||
import { LayoutService } from './core-ui/layout/layout.service';
|
||||
import { IPC } from '../../electron/shared-with-frontend/ipc-events.const';
|
||||
import { SnackService } from './core/snack/snack.service';
|
||||
import { IS_ELECTRON, LanguageCode } from './app.constants';
|
||||
import { BodyClass, IS_ELECTRON, LanguageCode } from './app.constants';
|
||||
import { expandAnimation } from './ui/animations/expand.ani';
|
||||
import { warpRouteAnimation } from './ui/animations/warp-route';
|
||||
import { combineLatest, Observable, Subscription } from 'rxjs';
|
||||
|
|
@ -45,13 +45,8 @@ import { IS_MOBILE } from './util/is-mobile';
|
|||
import { warpAnimation, warpInAnimation } from './ui/animations/warp.ani';
|
||||
import { GlobalConfigState } from './features/config/global-config.model';
|
||||
import { AddTaskBarComponent } from './features/tasks/add-task-bar/add-task-bar.component';
|
||||
import {
|
||||
MatSidenav,
|
||||
MatSidenavContainer,
|
||||
MatSidenavContent,
|
||||
} from '@angular/material/sidenav';
|
||||
import { Dir } from '@angular/cdk/bidi';
|
||||
import { SideNavComponent } from './core-ui/side-nav/side-nav.component';
|
||||
import { MagicSideNavComponent } from './core-ui/magic-side-nav/magic-side-nav.component';
|
||||
import { MainHeaderComponent } from './core-ui/main-header/main-header.component';
|
||||
import { BannerComponent } from './core/banner/banner/banner.component';
|
||||
import { GlobalProgressBarComponent } from './core-ui/global-progress-bar/global-progress-bar.component';
|
||||
|
|
@ -109,11 +104,8 @@ const productivityTip: string[] = w.productivityTips && w.productivityTips[w.ran
|
|||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
AddTaskBarComponent,
|
||||
MatSidenavContainer,
|
||||
Dir,
|
||||
MatSidenav,
|
||||
SideNavComponent,
|
||||
MatSidenavContent,
|
||||
MagicSideNavComponent,
|
||||
MainHeaderComponent,
|
||||
BannerComponent,
|
||||
RightPanelComponent,
|
||||
|
|
@ -195,6 +187,11 @@ export class AppComponent implements OnDestroy, AfterViewInit {
|
|||
initialValue: false,
|
||||
});
|
||||
|
||||
private readonly _activeWorkContextId = toSignal(
|
||||
this.workContextService.activeWorkContextId$,
|
||||
{ initialValue: null },
|
||||
);
|
||||
|
||||
private _subs: Subscription = new Subscription();
|
||||
private _intervalTimer?: NodeJS.Timeout;
|
||||
|
||||
|
|
@ -211,6 +208,17 @@ export class AppComponent implements OnDestroy, AfterViewInit {
|
|||
document.dir = this.isRTL ? 'rtl' : 'ltr';
|
||||
});
|
||||
|
||||
// Add/remove hasBgImage class to body when background image changes
|
||||
effect(() => {
|
||||
const bgImage = this.globalThemeService.backgroundImg();
|
||||
const bodyEl = document.body;
|
||||
if (bgImage) {
|
||||
bodyEl.classList.add(BodyClass.hasBgImage);
|
||||
} else {
|
||||
bodyEl.classList.remove(BodyClass.hasBgImage);
|
||||
}
|
||||
});
|
||||
|
||||
this._subs.add(
|
||||
this._activatedRoute.queryParams.subscribe((params) => {
|
||||
if (!!params.focusItem) {
|
||||
|
|
@ -435,6 +443,10 @@ export class AppComponent implements OnDestroy, AfterViewInit {
|
|||
return outlet.activatedRouteData.page || 'one';
|
||||
}
|
||||
|
||||
getActiveWorkContextId(): string | null {
|
||||
return this._activeWorkContextId();
|
||||
}
|
||||
|
||||
changeBackgroundFromUnsplash(): void {
|
||||
const dialogRef = this._matDialog.open(DialogUnsplashPickerComponent, {
|
||||
width: '900px',
|
||||
|
|
|
|||
|
|
@ -114,6 +114,7 @@ export enum BodyClass {
|
|||
isEnabledBackgroundGradient = 'isEnabledBackgroundGradient',
|
||||
isDisableAnimations = 'isDisableAnimations',
|
||||
isDataImportInProgress = 'isDataImportInProgress',
|
||||
hasBgImage = 'hasBgImage',
|
||||
|
||||
isAndroidKeyboardShown = 'isAndroidKeyboardShown',
|
||||
isAndroidKeyboardHidden = 'isAndroidKeyboardHidden',
|
||||
|
|
|
|||
|
|
@ -3,34 +3,28 @@ import {
|
|||
hideAddTaskBar,
|
||||
hideIssuePanel,
|
||||
hideNonTaskSidePanelContent,
|
||||
hideSideNav,
|
||||
hideTaskViewCustomizerPanel,
|
||||
showAddTaskBar,
|
||||
toggleIssuePanel,
|
||||
toggleShowNotes,
|
||||
toggleSideNav,
|
||||
toggleTaskViewCustomizerPanel,
|
||||
} from './store/layout.actions';
|
||||
import { merge, Observable } from 'rxjs';
|
||||
import { Observable } from 'rxjs';
|
||||
import { select, Store } from '@ngrx/store';
|
||||
import {
|
||||
LayoutState,
|
||||
selectIsShowAddTaskBar,
|
||||
selectIsShowIssuePanel,
|
||||
selectIsShowNotes,
|
||||
selectIsShowSideNav,
|
||||
selectIsShowTaskViewCustomizerPanel,
|
||||
} from './store/layout.reducer';
|
||||
import { filter, map, startWith } from 'rxjs/operators';
|
||||
import { BreakpointObserver } from '@angular/cdk/layout';
|
||||
import { NavigationEnd, NavigationStart, Router } from '@angular/router';
|
||||
import { WorkContextService } from '../../features/work-context/work-context.service';
|
||||
import { selectMiscConfig } from '../../features/config/store/global-config.reducer';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
|
||||
const NAV_ALWAYS_VISIBLE = 1200;
|
||||
const NAV_OVER_RIGHT_PANEL_NEXT = 800;
|
||||
const BOTH_OVER = 720;
|
||||
const NAV_ALWAYS_VISIBLE = 600;
|
||||
const RIGHT_PANEL_OVER = 720;
|
||||
const VERY_BIG_SCREEN = NAV_ALWAYS_VISIBLE;
|
||||
|
||||
@Injectable({
|
||||
|
|
@ -39,7 +33,6 @@ const VERY_BIG_SCREEN = NAV_ALWAYS_VISIBLE;
|
|||
export class LayoutService {
|
||||
private _store$ = inject<Store<LayoutState>>(Store);
|
||||
private _router = inject(Router);
|
||||
private _workContextService = inject(WorkContextService);
|
||||
private _breakPointObserver = inject(BreakpointObserver);
|
||||
private _previouslyFocusedElement: HTMLElement | null = null;
|
||||
|
||||
|
|
@ -47,9 +40,6 @@ export class LayoutService {
|
|||
readonly isShowAddTaskBar$: Observable<boolean> = this._store$.pipe(
|
||||
select(selectIsShowAddTaskBar),
|
||||
);
|
||||
readonly isShowSideNav$: Observable<boolean> = this._store$.pipe(
|
||||
select(selectIsShowSideNav),
|
||||
);
|
||||
readonly isShowIssuePanel$: Observable<boolean> = this._store$.pipe(
|
||||
select(selectIsShowIssuePanel),
|
||||
);
|
||||
|
|
@ -58,23 +48,16 @@ export class LayoutService {
|
|||
readonly isScrolled = signal<boolean>(false);
|
||||
readonly isShowAddTaskBar = toSignal(this.isShowAddTaskBar$, { initialValue: false });
|
||||
|
||||
readonly isNavAlwaysVisible = toSignal(
|
||||
readonly isMobileNav = toSignal(
|
||||
this._breakPointObserver
|
||||
.observe([`(min-width: ${NAV_ALWAYS_VISIBLE}px)`])
|
||||
.pipe(map((result) => result.matches)),
|
||||
{ initialValue: false },
|
||||
);
|
||||
|
||||
readonly isRightPanelNextNavOver = toSignal(
|
||||
this._breakPointObserver
|
||||
.observe([`(min-width: ${NAV_OVER_RIGHT_PANEL_NEXT}px)`])
|
||||
.pipe(map((result) => result.matches)),
|
||||
{ initialValue: false },
|
||||
);
|
||||
|
||||
readonly isRightPanelOver = toSignal(
|
||||
this._breakPointObserver
|
||||
.observe([`(min-width: ${BOTH_OVER}px)`])
|
||||
.observe([`(min-width: ${RIGHT_PANEL_OVER}px)`])
|
||||
.pipe(map((result) => !result.matches)),
|
||||
{ initialValue: false },
|
||||
);
|
||||
|
|
@ -115,29 +98,6 @@ export class LayoutService {
|
|||
return url.includes('/active/') || url.includes('/tag/') || url.includes('/project/');
|
||||
}
|
||||
|
||||
// Convert misc config to signal
|
||||
private readonly _miscConfig = toSignal(this._store$.select(selectMiscConfig), {
|
||||
initialValue: undefined,
|
||||
});
|
||||
|
||||
// Computed signal for nav over state
|
||||
readonly isNavOver = computed(() => {
|
||||
const miscCfg = this._miscConfig();
|
||||
if (miscCfg?.isUseMinimalNav) {
|
||||
return false;
|
||||
}
|
||||
return !this.isRightPanelNextNavOver();
|
||||
});
|
||||
|
||||
private readonly _isShowSideNav = toSignal(this.isShowSideNav$, {
|
||||
initialValue: false,
|
||||
});
|
||||
|
||||
readonly isShowSideNav = computed(() => {
|
||||
const isShow = this._isShowSideNav();
|
||||
return isShow || this.isNavAlwaysVisible();
|
||||
});
|
||||
|
||||
readonly isShowNotes = toSignal(this._store$.pipe(select(selectIsShowNotes)), {
|
||||
initialValue: false,
|
||||
});
|
||||
|
|
@ -149,19 +109,6 @@ export class LayoutService {
|
|||
|
||||
readonly isShowIssuePanel = toSignal(this.isShowIssuePanel$, { initialValue: false });
|
||||
|
||||
// Subscribe to navigation events to hide sidenav
|
||||
constructor() {
|
||||
// Only hide sidenav on actual navigation, not on initial load
|
||||
merge(
|
||||
this._router.events.pipe(filter((ev) => ev instanceof NavigationStart)),
|
||||
this._workContextService.onWorkContextChange$,
|
||||
).subscribe(() => {
|
||||
if (this.isNavOver() && this._isShowSideNav()) {
|
||||
this.hideSideNav();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
showAddTaskBar(): void {
|
||||
// Store currently focused element if it's a task
|
||||
const activeElement = document.activeElement as HTMLElement;
|
||||
|
|
@ -187,14 +134,6 @@ export class LayoutService {
|
|||
}
|
||||
}
|
||||
|
||||
toggleSideNav(): void {
|
||||
this._store$.dispatch(toggleSideNav());
|
||||
}
|
||||
|
||||
hideSideNav(): void {
|
||||
this._store$.dispatch(hideSideNav());
|
||||
}
|
||||
|
||||
toggleNotes(): void {
|
||||
this._store$.dispatch(toggleShowNotes());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,10 +16,6 @@ export const showAddTaskBar = createAction('[Layout] Show AddTaskBar');
|
|||
|
||||
export const hideAddTaskBar = createAction('[Layout] Hide AddTaskBar');
|
||||
|
||||
export const hideSideNav = createAction('[Layout] Hide SideBar');
|
||||
|
||||
export const toggleSideNav = createAction('[Layout] Toggle SideBar');
|
||||
|
||||
export const toggleShowNotes = createAction('[Layout] ToggleShow Notes');
|
||||
|
||||
export const hideNonTaskSidePanelContent = createAction(
|
||||
|
|
|
|||
|
|
@ -2,13 +2,11 @@ import {
|
|||
hideAddTaskBar,
|
||||
hideNonTaskSidePanelContent,
|
||||
hidePluginPanel,
|
||||
hideSideNav,
|
||||
showAddTaskBar,
|
||||
showPluginPanel,
|
||||
toggleIssuePanel,
|
||||
togglePluginPanel,
|
||||
toggleShowNotes,
|
||||
toggleSideNav,
|
||||
toggleTaskViewCustomizerPanel,
|
||||
} from './layout.actions';
|
||||
import {
|
||||
|
|
@ -25,7 +23,6 @@ export interface LayoutState {
|
|||
isShowAddTaskBar: boolean;
|
||||
isShowNotes: boolean;
|
||||
isShowIssuePanel: boolean;
|
||||
isShowSideNav: boolean;
|
||||
isShowCelebrate: boolean;
|
||||
isShowTaskViewCustomizerPanel: boolean;
|
||||
isShowPluginPanel: boolean;
|
||||
|
|
@ -34,7 +31,6 @@ export interface LayoutState {
|
|||
|
||||
export const INITIAL_LAYOUT_STATE: LayoutState = {
|
||||
isShowAddTaskBar: false,
|
||||
isShowSideNav: false,
|
||||
isShowNotes: false,
|
||||
isShowIssuePanel: false,
|
||||
isShowCelebrate: false,
|
||||
|
|
@ -51,11 +47,6 @@ export const selectIsShowAddTaskBar = createSelector(
|
|||
(state) => state.isShowAddTaskBar,
|
||||
);
|
||||
|
||||
export const selectIsShowSideNav = createSelector(
|
||||
selectLayoutFeatureState,
|
||||
(state) => state.isShowSideNav,
|
||||
);
|
||||
|
||||
export const selectIsShowNotes = createSelector(
|
||||
selectLayoutFeatureState,
|
||||
(state) => state.isShowNotes,
|
||||
|
|
@ -101,10 +92,6 @@ const _reducer = createReducer<LayoutState>(
|
|||
|
||||
on(hideAddTaskBar, (state) => ({ ...state, isShowAddTaskBar: false })),
|
||||
|
||||
on(hideSideNav, (state) => ({ ...state, isShowSideNav: false })),
|
||||
|
||||
on(toggleSideNav, (state) => ({ ...state, isShowSideNav: !state.isShowSideNav })),
|
||||
|
||||
on(toggleShowNotes, (state) => ({
|
||||
...state,
|
||||
...ALL_PANEL_CONTENT_HIDDEN,
|
||||
|
|
|
|||
430
src/app/core-ui/magic-side-nav/magic-nav-config.service.ts
Normal file
430
src/app/core-ui/magic-side-nav/magic-nav-config.service.ts
Normal file
|
|
@ -0,0 +1,430 @@
|
|||
import { computed, inject, Injectable, signal } from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
|
||||
import { first } from 'rxjs/operators';
|
||||
|
||||
import { WorkContextType } from '../../features/work-context/work-context.model';
|
||||
import { WorkContextService } from '../../features/work-context/work-context.service';
|
||||
import { TagService } from '../../features/tag/tag.service';
|
||||
import { ProjectService } from '../../features/project/project.service';
|
||||
import { ShepherdService } from '../../features/shepherd/shepherd.service';
|
||||
import { TourId } from '../../features/shepherd/shepherd-steps.const';
|
||||
import { T } from '../../t.const';
|
||||
import { LS } from '../../core/persistence/storage-keys.const';
|
||||
import { DialogCreateProjectComponent } from '../../features/project/dialogs/create-project/dialog-create-project.component';
|
||||
import { getGithubErrorUrl } from '../../core/error-handler/global-error-handler.util';
|
||||
import {
|
||||
selectAllProjectsExceptInbox,
|
||||
selectUnarchivedHiddenProjectIds,
|
||||
selectUnarchivedVisibleProjects,
|
||||
} from '../../features/project/store/project.selectors';
|
||||
import { toggleHideFromMenu } from '../../features/project/store/project.actions';
|
||||
import { NavConfig, NavItem, NavWorkContextItem } from './magic-side-nav.model';
|
||||
import { TODAY_TAG } from '../../features/tag/tag.const';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class MagicNavConfigService {
|
||||
private readonly _workContextService = inject(WorkContextService);
|
||||
private readonly _tagService = inject(TagService);
|
||||
private readonly _projectService = inject(ProjectService);
|
||||
private readonly _shepherdService = inject(ShepherdService);
|
||||
private readonly _matDialog = inject(MatDialog);
|
||||
private readonly _store = inject(Store);
|
||||
|
||||
// Simple state signals
|
||||
private readonly _isProjectsExpanded = signal(
|
||||
this._getStoredState(LS.IS_PROJECT_LIST_EXPANDED),
|
||||
);
|
||||
private readonly _isTagsExpanded = signal(
|
||||
this._getStoredState(LS.IS_TAG_LIST_EXPANDED),
|
||||
);
|
||||
|
||||
// Data signals
|
||||
private readonly _mainWorkContext = toSignal(
|
||||
this._workContextService.mainWorkContext$,
|
||||
{ initialValue: null },
|
||||
);
|
||||
private readonly _inboxContext = toSignal(this._workContextService.inboxWorkContext$, {
|
||||
initialValue: null,
|
||||
});
|
||||
private readonly _visibleProjects = toSignal(
|
||||
this._store.select(selectUnarchivedVisibleProjects),
|
||||
{ initialValue: [] },
|
||||
);
|
||||
private readonly _allProjectsExceptInbox = toSignal(
|
||||
this._store.select(selectAllProjectsExceptInbox),
|
||||
{ initialValue: [] },
|
||||
);
|
||||
private readonly _tags = toSignal(this._tagService.tagsNoMyDayAndNoList$, {
|
||||
initialValue: [],
|
||||
});
|
||||
private readonly _activeWorkContextId = toSignal(
|
||||
this._workContextService.activeWorkContextId$,
|
||||
{ initialValue: null },
|
||||
);
|
||||
|
||||
// Main navigation configuration
|
||||
readonly navConfig = computed<NavConfig>(() => ({
|
||||
items: [
|
||||
// Work Context Items
|
||||
...this._buildWorkContextItems(),
|
||||
|
||||
// Separator
|
||||
|
||||
// Main Routes
|
||||
{
|
||||
type: 'route',
|
||||
id: 'schedule',
|
||||
label: T.MH.SCHEDULE,
|
||||
icon: 'early_on',
|
||||
svgIcon: 'early_on',
|
||||
|
||||
route: '/schedule',
|
||||
},
|
||||
{
|
||||
type: 'route',
|
||||
id: 'planner',
|
||||
label: T.MH.PLANNER,
|
||||
icon: 'edit_calendar',
|
||||
route: '/planner',
|
||||
},
|
||||
{
|
||||
type: 'route',
|
||||
id: 'boards',
|
||||
label: T.MH.BOARDS,
|
||||
icon: 'grid_view',
|
||||
route: '/boards',
|
||||
},
|
||||
|
||||
// Separator
|
||||
{ type: 'separator', id: 'sep-2' },
|
||||
|
||||
// Projects Section
|
||||
{
|
||||
type: 'group',
|
||||
id: 'projects',
|
||||
label: T.MH.PROJECTS,
|
||||
icon: 'expand_more',
|
||||
children: this._buildProjectItems(),
|
||||
action: () => this._toggleProjectsExpanded(),
|
||||
additionalButtons: [
|
||||
{
|
||||
id: 'project-visibility',
|
||||
icon: 'visibility',
|
||||
tooltip: 'Show/Hide Projects',
|
||||
action: () => this._openProjectVisibilityMenu(),
|
||||
},
|
||||
{
|
||||
id: 'add-project',
|
||||
icon: 'add',
|
||||
tooltip: T.MH.CREATE_PROJECT,
|
||||
action: () => this._openCreateProject(),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Tags Section
|
||||
{
|
||||
type: 'group',
|
||||
id: 'tags',
|
||||
label: T.MH.TAGS,
|
||||
icon: 'expand_more',
|
||||
children: this._buildTagItems(),
|
||||
action: () => this._toggleTagsExpanded(),
|
||||
},
|
||||
|
||||
// Separator
|
||||
{ type: 'separator', id: 'sep-3', mtAuto: true },
|
||||
|
||||
// App Section
|
||||
{
|
||||
type: 'route',
|
||||
id: 'search',
|
||||
label: T.MH.SEARCH,
|
||||
icon: 'search',
|
||||
route: '/search',
|
||||
},
|
||||
{
|
||||
type: 'route',
|
||||
id: 'scheduled-list',
|
||||
label: T.MH.ALL_PLANNED_LIST,
|
||||
icon: 'list',
|
||||
route: '/scheduled-list',
|
||||
},
|
||||
|
||||
// Help Menu (rendered as mat-menu)
|
||||
{
|
||||
type: 'menu',
|
||||
id: 'help',
|
||||
label: T.MH.HELP,
|
||||
icon: 'help_center',
|
||||
children: [
|
||||
{
|
||||
type: 'href',
|
||||
id: 'help-online',
|
||||
label: T.MH.HM.GET_HELP_ONLINE,
|
||||
icon: 'help_center',
|
||||
href: 'https://github.com/johannesjo/super-productivity/blob/master/README.md#question-how-to-use-it',
|
||||
},
|
||||
{
|
||||
type: 'action',
|
||||
id: 'help-report',
|
||||
label: T.MH.HM.REPORT_A_PROBLEM,
|
||||
icon: 'bug_report',
|
||||
action: () => this._openBugReport(),
|
||||
},
|
||||
{
|
||||
type: 'href',
|
||||
id: 'help-contribute',
|
||||
label: T.MH.HM.CONTRIBUTE,
|
||||
icon: 'volunteer_activism',
|
||||
href: 'https://github.com/johannesjo/super-productivity/blob/master/CONTRIBUTING.md',
|
||||
},
|
||||
{
|
||||
type: 'href',
|
||||
id: 'help-reddit',
|
||||
label: T.MH.HM.REDDIT_COMMUNITY,
|
||||
icon: 'forum',
|
||||
href: 'https://www.reddit.com/r/superProductivity/',
|
||||
},
|
||||
{
|
||||
type: 'action',
|
||||
id: 'tour-welcome',
|
||||
label: T.MH.HM.START_WELCOME,
|
||||
icon: 'directions',
|
||||
action: () => this._startTour(TourId.Welcome),
|
||||
},
|
||||
{
|
||||
type: 'action',
|
||||
id: 'tour-keyboard',
|
||||
label: T.MH.HM.KEYBOARD,
|
||||
icon: 'directions',
|
||||
action: () => this._startTour(TourId.KeyboardNav),
|
||||
},
|
||||
{
|
||||
type: 'action',
|
||||
id: 'tour-sync',
|
||||
label: T.MH.HM.SYNC,
|
||||
icon: 'directions',
|
||||
action: () => this._startTour(TourId.Sync),
|
||||
},
|
||||
{
|
||||
type: 'action',
|
||||
id: 'tour-calendars',
|
||||
label: T.MH.HM.CALENDARS,
|
||||
icon: 'directions',
|
||||
action: () => this._startTour(TourId.IssueProviders),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
type: 'route',
|
||||
id: 'settings',
|
||||
label: T.MH.SETTINGS,
|
||||
icon: 'settings',
|
||||
route: '/config',
|
||||
},
|
||||
],
|
||||
fullModeByDefault: true,
|
||||
showLabels: true,
|
||||
mobileBreakpoint: 600,
|
||||
position: 'left',
|
||||
theme: 'light',
|
||||
resizable: true,
|
||||
minWidth: 190,
|
||||
maxWidth: 400,
|
||||
defaultWidth: 260,
|
||||
collapseThreshold: 150,
|
||||
expandThreshold: 180,
|
||||
}));
|
||||
|
||||
// Simple action handler
|
||||
onNavItemClick(item: NavItem): void {
|
||||
switch (item.type) {
|
||||
case 'href':
|
||||
window.open(item.href, '_blank');
|
||||
break;
|
||||
case 'action':
|
||||
item.action?.();
|
||||
break;
|
||||
default:
|
||||
// Routes and groups handled elsewhere
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Private helpers
|
||||
private _buildWorkContextItems(): NavItem[] {
|
||||
const items: NavItem[] = [];
|
||||
const mainContext = this._mainWorkContext();
|
||||
const inboxContext = this._inboxContext();
|
||||
|
||||
if (mainContext) {
|
||||
items.push({
|
||||
type: 'workContext',
|
||||
id: `main-${mainContext.id}`,
|
||||
label: mainContext.title,
|
||||
icon: mainContext.icon || 'today',
|
||||
route: `/tag/${mainContext.id}/tasks`,
|
||||
workContext: mainContext,
|
||||
workContextType: WorkContextType.TAG,
|
||||
defaultIcon: 'today',
|
||||
});
|
||||
}
|
||||
|
||||
if (inboxContext) {
|
||||
items.push({
|
||||
type: 'workContext',
|
||||
id: `inbox-${inboxContext.id}`,
|
||||
label: inboxContext.title,
|
||||
icon: inboxContext.icon || 'inbox',
|
||||
route: `/project/${inboxContext.id}/tasks`,
|
||||
workContext: inboxContext,
|
||||
workContextType: WorkContextType.PROJECT,
|
||||
defaultIcon: 'inbox',
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
private _buildProjectItems(): NavItem[] {
|
||||
const projects = this._visibleProjects();
|
||||
const activeId = this._activeWorkContextId();
|
||||
|
||||
let filteredProjects = projects;
|
||||
if (!this._isProjectsExpanded() && activeId) {
|
||||
// Show only active project when group is collapsed
|
||||
filteredProjects = projects.filter((project) => project.id === activeId);
|
||||
}
|
||||
|
||||
return filteredProjects.map((project) => ({
|
||||
type: 'workContext',
|
||||
id: `project-${project.id}`,
|
||||
label: project.title,
|
||||
icon: project.icon || 'folder_special',
|
||||
route: `/project/${project.id}/tasks`,
|
||||
workContext: project,
|
||||
workContextType: WorkContextType.PROJECT,
|
||||
defaultIcon: project.icon || 'folder_special',
|
||||
}));
|
||||
}
|
||||
|
||||
private _buildTagItems(): NavItem[] {
|
||||
const tags = this._tags();
|
||||
const activeId = this._activeWorkContextId();
|
||||
|
||||
let filteredTags = tags;
|
||||
if (!this._isTagsExpanded() && activeId) {
|
||||
// Show only active tag when group is collapsed
|
||||
filteredTags = tags.filter((tag) => tag.id === activeId);
|
||||
}
|
||||
|
||||
return filteredTags.map((tag) => ({
|
||||
type: 'workContext',
|
||||
id: `tag-${tag.id}`,
|
||||
label: tag.title,
|
||||
icon: tag.icon || 'label',
|
||||
route: `/tag/${tag.id}/tasks`,
|
||||
workContext: tag,
|
||||
workContextType: WorkContextType.TAG,
|
||||
defaultIcon: tag.icon || 'label',
|
||||
}));
|
||||
}
|
||||
|
||||
// Public computed signals for expansion state (for component to check)
|
||||
readonly isProjectsExpanded = computed(() => this._isProjectsExpanded());
|
||||
readonly isTagsExpanded = computed(() => this._isTagsExpanded());
|
||||
|
||||
// Public access to projects for visibility menu
|
||||
readonly allProjectsExceptInbox = computed(() => this._allProjectsExceptInbox());
|
||||
|
||||
// Simple toggle functions
|
||||
private _toggleProjectsExpanded(): void {
|
||||
const newState = !this._isProjectsExpanded();
|
||||
this._isProjectsExpanded.set(newState);
|
||||
localStorage.setItem(LS.IS_PROJECT_LIST_EXPANDED, newState.toString());
|
||||
}
|
||||
|
||||
private _toggleTagsExpanded(): void {
|
||||
const newState = !this._isTagsExpanded();
|
||||
this._isTagsExpanded.set(newState);
|
||||
localStorage.setItem(LS.IS_TAG_LIST_EXPANDED, newState.toString());
|
||||
}
|
||||
|
||||
// Simple action handlers
|
||||
private _openCreateProject(): void {
|
||||
this._matDialog.open(DialogCreateProjectComponent, { restoreFocus: true });
|
||||
}
|
||||
|
||||
private _openBugReport(): void {
|
||||
window.open(getGithubErrorUrl('', undefined, true), '_blank');
|
||||
}
|
||||
|
||||
private _startTour(tourId: TourId): void {
|
||||
void this._shepherdService.show(tourId);
|
||||
}
|
||||
|
||||
private _openProjectVisibilityMenu(): void {
|
||||
// This will trigger the nav-mat-menu component to show the visibility options
|
||||
// The actual menu items are built by the component based on all projects
|
||||
}
|
||||
|
||||
toggleProjectVisibility(projectId: string): void {
|
||||
this._store.dispatch(toggleHideFromMenu({ id: projectId }));
|
||||
}
|
||||
|
||||
// Drag and drop handlers
|
||||
async handleProjectDrop(
|
||||
items: NavWorkContextItem[],
|
||||
event: CdkDragDrop<string, string, NavWorkContextItem>,
|
||||
): Promise<void> {
|
||||
if (event.previousIndex === event.currentIndex) return;
|
||||
|
||||
// Create a copy of the array and move the item
|
||||
const reorderedItems = [...items];
|
||||
moveItemInArray(reorderedItems, event.previousIndex, event.currentIndex);
|
||||
|
||||
// Get the new order of IDs
|
||||
const visibleIds = reorderedItems.map((item) => item.workContext!.id);
|
||||
|
||||
// Get hidden project IDs to append at the end
|
||||
const hiddenIds = await this._store
|
||||
.select(selectUnarchivedHiddenProjectIds)
|
||||
.pipe(first())
|
||||
.toPromise();
|
||||
|
||||
// Combine visible and hidden IDs
|
||||
const newIds = [...visibleIds, ...(hiddenIds || [])];
|
||||
this._projectService.updateOrder(newIds);
|
||||
}
|
||||
|
||||
handleTagDrop(
|
||||
items: NavWorkContextItem[],
|
||||
event: CdkDragDrop<string, string, NavWorkContextItem>,
|
||||
): void {
|
||||
if (event.previousIndex === event.currentIndex) return;
|
||||
|
||||
// Create a copy of the array and move the item
|
||||
const reorderedItems = [...items];
|
||||
moveItemInArray(reorderedItems, event.previousIndex, event.currentIndex);
|
||||
|
||||
// Get the new order of IDs
|
||||
const visibleIds = reorderedItems.map((item) => item.workContext!.id);
|
||||
|
||||
// Special today list should always be first, so prepend it
|
||||
const newIds = [TODAY_TAG.id, ...visibleIds.filter((id) => id !== TODAY_TAG.id)];
|
||||
this._tagService.updateOrder(newIds);
|
||||
}
|
||||
|
||||
// Helper
|
||||
private _getStoredState(key: string): boolean {
|
||||
const stored = localStorage.getItem(key);
|
||||
return stored === null || stored === 'true';
|
||||
}
|
||||
}
|
||||
169
src/app/core-ui/magic-side-nav/magic-side-nav.component.html
Normal file
169
src/app/core-ui/magic-side-nav/magic-side-nav.component.html
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
<!-- Mobile Menu Open Button (only visible on mobile when closed) -->
|
||||
@if (isMobile() && !showMobileMenuOverlay()) {
|
||||
<button
|
||||
class="mobile-menu-open"
|
||||
(click)="toggleMobileNav()"
|
||||
[attr.aria-label]="'Open menu'"
|
||||
>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M3 12h18M3 6h18M3 18h18" />
|
||||
</svg>
|
||||
</button>
|
||||
}
|
||||
|
||||
<!-- Overlay for mobile (backdrop) -->
|
||||
@if (isMobile() && showMobileMenuOverlay()) {
|
||||
<div
|
||||
class="nav-backdrop-mobile"
|
||||
(click)="toggleMobileNav()"
|
||||
></div>
|
||||
}
|
||||
<!-- Main Navigation Sidebar -->
|
||||
<nav
|
||||
class="nav-sidebar"
|
||||
[class.fullMode]="isFullMode() || isMobile()"
|
||||
[class.compactMode]="!isFullMode() && !isMobile()"
|
||||
[class.mobile]="isMobile()"
|
||||
[class.mobile-visible]="isMobile() && showMobileMenuOverlay()"
|
||||
[class.animate]="animateWidth()"
|
||||
[class.theme-dark]="config().theme === 'dark'"
|
||||
[class.position-right]="config().position === 'right'"
|
||||
[class.resizing]="isResizing()"
|
||||
[style.width.px]="sidebarWidth()"
|
||||
role="navigation"
|
||||
(keydown)="onNavKeyDown($event)"
|
||||
>
|
||||
<!-- Desktop Toggle Button (inside sidebar) -->
|
||||
@if (!isMobile()) {
|
||||
<button
|
||||
class="sidebar-toggle"
|
||||
[class.visible]="!isMobile()"
|
||||
(click)="toggleSideNavMode()"
|
||||
[attr.aria-label]="isFullMode() ? 'Switch to compact mode' : 'Switch to full mode'"
|
||||
[attr.aria-expanded]="isFullMode()"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path [attr.d]="isFullMode() ? 'M13 4l-6 6 6 6' : 'M7 4l6 6-6 6'" />
|
||||
</svg>
|
||||
</button>
|
||||
}
|
||||
|
||||
<!-- Navigation Items -->
|
||||
<ul
|
||||
class="nav-list"
|
||||
role="list"
|
||||
>
|
||||
@for (item of config().items; track item.id) {
|
||||
@switch (item.type) {
|
||||
@case ('separator') {
|
||||
<hr
|
||||
class="nav-separator"
|
||||
[style.margin-top]="item.mtAuto && 'auto'"
|
||||
/>
|
||||
}
|
||||
@case ('group') {
|
||||
<li
|
||||
class="nav-item has-children"
|
||||
role="listitem"
|
||||
>
|
||||
<nav-list
|
||||
[item]="item"
|
||||
[showLabels]="showText()"
|
||||
[isExpanded]="isGroupExpanded(item)"
|
||||
[activeWorkContextId]="activeWorkContextId()"
|
||||
(itemClick)="onItemClick($event)"
|
||||
(dragDrop)="onDragDrop(item, $event)"
|
||||
></nav-list>
|
||||
</li>
|
||||
}
|
||||
@case ('menu') {
|
||||
<nav-mat-menu
|
||||
[item]="item"
|
||||
[showLabels]="showText()"
|
||||
(itemClick)="onItemClick($event)"
|
||||
></nav-mat-menu>
|
||||
}
|
||||
@default {
|
||||
<li
|
||||
class="nav-item"
|
||||
role="listitem"
|
||||
>
|
||||
@switch (item.type) {
|
||||
@case ('workContext') {
|
||||
<nav-item
|
||||
[workContext]="item.workContext"
|
||||
[type]="item.workContextType"
|
||||
[defaultIcon]="item.defaultIcon"
|
||||
[activeWorkContextId]="activeWorkContextId() || ''"
|
||||
[variant]="'nav'"
|
||||
[showLabels]="showText()"
|
||||
[showMoreButton]="showText()"
|
||||
(clicked)="onItemClick(item)"
|
||||
></nav-item>
|
||||
}
|
||||
@case ('route') {
|
||||
<nav-item
|
||||
[container]="'route'"
|
||||
[navRoute]="item.route"
|
||||
[icon]="item.icon"
|
||||
[svgIcon]="item.svgIcon"
|
||||
[label]="item.label"
|
||||
[showLabels]="showText()"
|
||||
(clicked)="onItemClick(item)"
|
||||
></nav-item>
|
||||
}
|
||||
@case ('href') {
|
||||
<nav-item
|
||||
[container]="'href'"
|
||||
[navHref]="item.href"
|
||||
[icon]="item.icon"
|
||||
[svgIcon]="item.svgIcon"
|
||||
[label]="item.label"
|
||||
[showLabels]="showText()"
|
||||
(clicked)="onItemClick(item)"
|
||||
></nav-item>
|
||||
}
|
||||
@case ('action') {
|
||||
<nav-item
|
||||
[container]="'action'"
|
||||
[icon]="item.icon"
|
||||
[svgIcon]="item.svgIcon"
|
||||
[label]="item.label"
|
||||
[showLabels]="showText()"
|
||||
(clicked)="onItemClick(item)"
|
||||
></nav-item>
|
||||
}
|
||||
}
|
||||
</li>
|
||||
}
|
||||
}
|
||||
}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<!-- Resize Handle (moved outside scrolling container) -->
|
||||
@if (config().resizable && !isMobile()) {
|
||||
<div
|
||||
class="resize-handle"
|
||||
[class.position-left]="config().position !== 'right'"
|
||||
[class.position-right]="config().position === 'right'"
|
||||
[class.compactMode-handle]="!isFullMode()"
|
||||
(mousedown)="onResizeStart($event)"
|
||||
[attr.aria-label]="'Resize sidebar'"
|
||||
role="separator"
|
||||
></div>
|
||||
}
|
||||
291
src/app/core-ui/magic-side-nav/magic-side-nav.component.scss
Normal file
291
src/app/core-ui/magic-side-nav/magic-side-nav.component.scss
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
:host {
|
||||
--sidebar-width-expanded: var(--side-nav-width, 260px);
|
||||
--mobile-breakpoint: 768px;
|
||||
--sidebar-transition-duration: var(--transition-duration-m, 0.3s);
|
||||
--sidebar-transition-timing: var(--ani-standard-timing, cubic-bezier(0.4, 0, 0.2, 1));
|
||||
--mat-menu-item-spacing: 4px;
|
||||
|
||||
// Ensure the component itself participates in layout and animates width
|
||||
display: block;
|
||||
flex: 0 0 auto;
|
||||
transition: none;
|
||||
will-change: width;
|
||||
position: relative;
|
||||
z-index: var(--z-side-nav);
|
||||
}
|
||||
|
||||
// Enable host width transition only when toggling compactMode/fullMode
|
||||
:host(.animate) {
|
||||
transition: width var(--sidebar-transition-duration) var(--sidebar-transition-timing);
|
||||
}
|
||||
|
||||
// Disable host transition during resize drags
|
||||
:host(.resizing) {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
// Mobile menu open button (only shows when menu is closed)
|
||||
.mobile-menu-open {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: fixed;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
background: var(--sidenav-bg);
|
||||
border: 1px solid var(--sidebar-border);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
transition: var(--transition-standard);
|
||||
color: var(--text-color);
|
||||
|
||||
&:hover {
|
||||
background: var(--sidebar-hover);
|
||||
}
|
||||
|
||||
svg {
|
||||
color: var(--sidebar-text);
|
||||
}
|
||||
}
|
||||
|
||||
// Dark theme milkglass adjustments
|
||||
:host-context(.isDarkTheme.hasBgImage) :host {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.08);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%); // Safari support
|
||||
}
|
||||
|
||||
// Mobile overlay
|
||||
.nav-backdrop-mobile {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: var(--overlay-bg);
|
||||
z-index: var(--z-backdrop, 1001);
|
||||
transition: opacity var(--sidebar-transition-duration) var(--sidebar-transition-timing);
|
||||
display: block;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
// Main sidebar
|
||||
.nav-sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: var(--sidebar-width-expanded);
|
||||
background: var(--sidenav-bg);
|
||||
border-right: 1px solid var(--sidebar-border);
|
||||
// Default: don't animate width
|
||||
transition: none;
|
||||
z-index: 1002;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
|
||||
// Slight elevation and clear background for overlay/mobile mode is defined in the mobile block below
|
||||
|
||||
&.animate {
|
||||
transition: width var(--sidebar-transition-duration) var(--sidebar-transition-timing);
|
||||
}
|
||||
|
||||
&.resizing {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
// When resizing, also suppress label/text transitions inside entries
|
||||
&.resizing .nav-label,
|
||||
&.resizing .text {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
// Milkglass effect when background image is present
|
||||
// This will be applied when a CSS class is added to indicate background image presence
|
||||
:host-context(.hasBgImage) {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.1);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%); // Safari support
|
||||
}
|
||||
|
||||
// removed unused expand/collapse zone visuals
|
||||
|
||||
// Scrollbar styling
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--sidebar-border);
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover {
|
||||
background: var(--sidebar-text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
// CompactMode state (desktop)
|
||||
&.compactMode {
|
||||
// width controlled via inline style for smooth transitions
|
||||
|
||||
.sidebar-toggle {
|
||||
right: auto;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile specific
|
||||
&.mobile {
|
||||
transition: transform var(--sidebar-transition-duration)
|
||||
var(--sidebar-transition-timing);
|
||||
will-change: transform;
|
||||
transform: translateX(-100%);
|
||||
width: var(--sidebar-width-expanded);
|
||||
background: var(--sidenav-bg-mobile);
|
||||
|
||||
&.mobile-visible {
|
||||
box-shadow: 4px 0 16px rgba(0, 0, 0, 0.3);
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Position right
|
||||
&.position-right {
|
||||
left: auto;
|
||||
right: 0;
|
||||
border-right: none;
|
||||
border-left: 1px solid var(--sidebar-border);
|
||||
|
||||
&.mobile {
|
||||
transform: translateX(100%);
|
||||
|
||||
&.mobile-visible {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-toggle {
|
||||
left: 16px;
|
||||
right: auto;
|
||||
}
|
||||
|
||||
// removed unused collapse/expand zone visuals for right-position
|
||||
}
|
||||
|
||||
// Note: Theme colors are handled by CSS variables above
|
||||
}
|
||||
|
||||
// Sidebar toggle button (desktop)
|
||||
.sidebar-toggle {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--sidebar-border);
|
||||
border-radius: 6px;
|
||||
padding: 6px;
|
||||
cursor: pointer;
|
||||
transition: var(--transition-standard);
|
||||
color: var(--sidebar-text-secondary);
|
||||
|
||||
&.visible {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--sidebar-hover);
|
||||
color: var(--sidebar-text);
|
||||
}
|
||||
}
|
||||
|
||||
// Navigation list
|
||||
.nav-list {
|
||||
list-style: none;
|
||||
//padding: 60px var(--s) var(--s2);
|
||||
padding: 0;
|
||||
padding-top: 60px;
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
|
||||
// Remove top padding on mobile (600px breakpoint)
|
||||
@media (max-width: 600px) {
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
margin-bottom: 2px;
|
||||
|
||||
&.has-children {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
// Separator styling
|
||||
.nav-separator {
|
||||
height: 1px;
|
||||
background: var(--divider-color);
|
||||
margin: var(--s2) var(--s2);
|
||||
border: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
// Resize handle (now positioned relative to host component)
|
||||
.resize-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
cursor: col-resize;
|
||||
z-index: 1003; // Above the nav-sidebar
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&.position-left {
|
||||
right: -2px;
|
||||
}
|
||||
|
||||
&.position-right {
|
||||
left: -2px;
|
||||
}
|
||||
|
||||
// CompactMode specific styling
|
||||
&.compactMode-handle {
|
||||
width: 8px;
|
||||
|
||||
&.position-left {
|
||||
right: -4px;
|
||||
}
|
||||
|
||||
&.position-right {
|
||||
left: -4px;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--sidenav-active-text);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: var(--sidenav-active-text);
|
||||
}
|
||||
}
|
||||
|
||||
// Multi-button wrapper styling moved to nav-list component
|
||||
|
||||
// Media-query rules affecting link rows moved to nav-item component
|
||||
476
src/app/core-ui/magic-side-nav/magic-side-nav.component.ts
Normal file
476
src/app/core-ui/magic-side-nav/magic-side-nav.component.ts
Normal file
|
|
@ -0,0 +1,476 @@
|
|||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
HostListener,
|
||||
inject,
|
||||
input,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { CdkDragDrop } from '@angular/cdk/drag-drop';
|
||||
import { NavItemComponent } from './nav-item/nav-item.component';
|
||||
import { NavSectionComponent } from './nav-list/nav-list.component';
|
||||
import { NavGroupItem, NavItem, NavWorkContextItem } from './magic-side-nav.model';
|
||||
import { LS } from '../../core/persistence/storage-keys.const';
|
||||
import { MagicNavConfigService } from './magic-nav-config.service';
|
||||
import { readBoolLS, readNumberLSBounded } from '../../util/ls-util';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { NavMatMenuComponent } from './nav-mat-menu/nav-mat-menu.component';
|
||||
import { TaskService } from '../../features/tasks/task.service';
|
||||
|
||||
const COLLAPSED_WIDTH = 64;
|
||||
|
||||
@Component({
|
||||
selector: 'magic-side-nav',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
NavItemComponent,
|
||||
NavSectionComponent,
|
||||
MatMenuModule,
|
||||
NavMatMenuComponent,
|
||||
],
|
||||
templateUrl: './magic-side-nav.component.html',
|
||||
styleUrl: './magic-side-nav.component.scss',
|
||||
host: {
|
||||
'[style.width.px]': 'hostWidthSignal()',
|
||||
'[class.animate]': 'animateWidth()',
|
||||
'[class.resizing]': 'isResizing()',
|
||||
},
|
||||
})
|
||||
export class MagicSideNavComponent implements OnInit, OnDestroy {
|
||||
private readonly _sideNavConfigService = inject(MagicNavConfigService);
|
||||
private readonly _taskService = inject(TaskService);
|
||||
// Use service's computed signal directly
|
||||
readonly config = this._sideNavConfigService.navConfig;
|
||||
|
||||
activeWorkContextId = input<string | null>(null);
|
||||
|
||||
// Externally controlled mobile overlay visibility
|
||||
mobileVisibleChange = output<boolean>();
|
||||
|
||||
isFullMode = signal(true);
|
||||
isMobile = signal(false);
|
||||
showMobileMenuOverlay = signal(false);
|
||||
// Track expanded groups as array for better signal change detection
|
||||
expandedGroups = signal<string[]>([]);
|
||||
// Merge service-controlled and local expanded ids for reactive checks
|
||||
|
||||
// Animate only for compactMode/fullMode toggle
|
||||
animateWidth = signal(false);
|
||||
private _animateTimeoutId: number | null = null;
|
||||
|
||||
// Resize functionality
|
||||
currentWidth = signal(260);
|
||||
// Use values directly from config for min/max/thresholds
|
||||
isResizing = signal(false);
|
||||
startX = signal(0);
|
||||
startWidth = signal(0);
|
||||
|
||||
// Computed values
|
||||
sidebarWidth = computed(() => {
|
||||
if (this.isMobile()) return 260;
|
||||
if (!this.isFullMode()) return COLLAPSED_WIDTH;
|
||||
return this.currentWidth();
|
||||
});
|
||||
|
||||
// Host width as computed signal: don't reserve space on mobile overlay
|
||||
readonly hostWidthSignal = computed(() => (this.isMobile() ? 0 : this.sidebarWidth()));
|
||||
|
||||
// Commonly used derived state for template readability
|
||||
readonly showText = computed(() => this.isFullMode() || this.isMobile());
|
||||
|
||||
// Keep stable references for event listeners to ensure proper cleanup
|
||||
private readonly _onDrag: (event: MouseEvent) => void = (event: MouseEvent) =>
|
||||
this._handleDrag(event);
|
||||
private readonly _onDragEnd: () => void = () => this._handleDragEnd();
|
||||
|
||||
constructor() {
|
||||
// Emit mobile visible changes to parent while in mobile mode
|
||||
effect(() => {
|
||||
if (this.isMobile()) {
|
||||
this.mobileVisibleChange.emit(this.showMobileMenuOverlay());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (this._animateTimeoutId != null) {
|
||||
window.clearTimeout(this._animateTimeoutId);
|
||||
this._animateTimeoutId = null;
|
||||
}
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
// Load saved fullMode/compactMode state or default to config value
|
||||
const initialFullMode = readBoolLS(
|
||||
LS.NAV_SIDEBAR_EXPANDED,
|
||||
this.config().fullModeByDefault,
|
||||
);
|
||||
this.isFullMode.set(initialFullMode);
|
||||
|
||||
// Load saved width from localStorage or default
|
||||
const bounded = readNumberLSBounded(
|
||||
LS.NAV_SIDEBAR_WIDTH,
|
||||
this.config().minWidth,
|
||||
this.config().maxWidth,
|
||||
);
|
||||
this.currentWidth.set(bounded ?? this.config().defaultWidth);
|
||||
|
||||
// Initialize expanded groups for non-service-managed groups
|
||||
// Projects and tags expansion is managed by the service
|
||||
const nextExpanded: string[] = [];
|
||||
this.expandedGroups.set(nextExpanded);
|
||||
|
||||
this._checkScreenSize();
|
||||
}
|
||||
|
||||
@HostListener('window:resize')
|
||||
onWindowResize(): void {
|
||||
this._checkScreenSize();
|
||||
}
|
||||
|
||||
onNavKeyDown(event: KeyboardEvent): void {
|
||||
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
|
||||
this._handleArrowNavigation(event);
|
||||
} else if (event.key === 'Escape') {
|
||||
this._handleEscapeKey(event);
|
||||
}
|
||||
}
|
||||
|
||||
private _checkScreenSize(): void {
|
||||
const wasMobile = this.isMobile();
|
||||
const currentMobile = window.innerWidth < (this.config().mobileBreakpoint || 768);
|
||||
this.isMobile.set(currentMobile);
|
||||
|
||||
if (wasMobile !== currentMobile) {
|
||||
if (currentMobile) {
|
||||
this.showMobileMenuOverlay.set(false);
|
||||
} else {
|
||||
this.isFullMode.set(this.config().fullModeByDefault);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toggleMobileNav(): void {
|
||||
this.showMobileMenuOverlay.update((show) => !show);
|
||||
console.log(this.showMobileMenuOverlay());
|
||||
}
|
||||
|
||||
toggleSideNavMode(): void {
|
||||
this._enableWidthAnimation();
|
||||
const newFullMode = !this.isFullMode();
|
||||
this.isFullMode.set(newFullMode);
|
||||
// Save fullMode/compactMode state to localStorage
|
||||
localStorage.setItem(LS.NAV_SIDEBAR_EXPANDED, newFullMode.toString());
|
||||
// handled internally
|
||||
}
|
||||
|
||||
toggleGroup(item: NavItem): void {
|
||||
if (item.type !== 'group' || !item.children) {
|
||||
return;
|
||||
}
|
||||
const groups = this.expandedGroups();
|
||||
if (groups.includes(item.id)) {
|
||||
this.expandedGroups.set(groups.filter((g) => g !== item.id));
|
||||
} else {
|
||||
this.expandedGroups.set([...groups, item.id]);
|
||||
}
|
||||
}
|
||||
|
||||
isGroupExpanded(item: NavItem): boolean {
|
||||
// Use the service as the source of truth for expansion state
|
||||
if (item.id === 'projects') {
|
||||
return this._sideNavConfigService.isProjectsExpanded();
|
||||
} else if (item.id === 'tags') {
|
||||
return this._sideNavConfigService.isTagsExpanded();
|
||||
}
|
||||
// For other groups, fall back to local state
|
||||
return this.expandedGroups().includes(item.id);
|
||||
}
|
||||
|
||||
onItemClick(item: NavItem): void {
|
||||
if (item.type === 'group') {
|
||||
// For projects and tags, let the service handle the toggle to avoid double-toggle
|
||||
if (item.id === 'projects' || item.id === 'tags') {
|
||||
if (item.action) {
|
||||
item.action(); // This will update the service state
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// For other groups, handle locally
|
||||
if (item.action) {
|
||||
item.action();
|
||||
}
|
||||
this.toggleGroup(item);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.type === 'action') {
|
||||
item.action?.();
|
||||
}
|
||||
|
||||
// Handle via service for actions/hrefs
|
||||
this._sideNavConfigService.onNavItemClick(item);
|
||||
|
||||
if (this.isMobile()) {
|
||||
this.showMobileMenuOverlay.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
onDragDrop(
|
||||
groupItem: NavGroupItem,
|
||||
dropData: {
|
||||
items: NavWorkContextItem[];
|
||||
event: CdkDragDrop<string, string, NavWorkContextItem>;
|
||||
},
|
||||
): void {
|
||||
const { items, event } = dropData;
|
||||
|
||||
if (groupItem.id === 'projects') {
|
||||
this._sideNavConfigService.handleProjectDrop(items, event);
|
||||
} else if (groupItem.id === 'tags') {
|
||||
this._sideNavConfigService.handleTagDrop(items, event);
|
||||
}
|
||||
}
|
||||
|
||||
// Resize functionality
|
||||
onResizeStart(event: MouseEvent): void {
|
||||
if (!this.config().resizable || this.isMobile()) return;
|
||||
|
||||
this.isResizing.set(true);
|
||||
this.startX.set(event.clientX);
|
||||
this.startWidth.set(this.isFullMode() ? this.currentWidth() : COLLAPSED_WIDTH);
|
||||
|
||||
document.addEventListener('mousemove', this._onDrag);
|
||||
document.addEventListener('mouseup', this._onDragEnd);
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
private _handleDrag(event: MouseEvent): void {
|
||||
if (!this.isResizing()) return;
|
||||
|
||||
const deltaX =
|
||||
this.config().position === 'right'
|
||||
? this.startX() - event.clientX
|
||||
: event.clientX - this.startX();
|
||||
|
||||
const potentialWidth = this.startWidth() + deltaX;
|
||||
const cfg = this.config();
|
||||
const collapseTh = cfg.collapseThreshold;
|
||||
const expandTh = cfg.expandThreshold;
|
||||
const minW = cfg.minWidth;
|
||||
const maxW = cfg.maxWidth;
|
||||
|
||||
// Handle seamless mode transitions
|
||||
if (this.isFullMode()) {
|
||||
// Currently fullMode - check for compactMode threshold
|
||||
if (potentialWidth < collapseTh) {
|
||||
// Auto-switch to compactMode without releasing mouse
|
||||
this.isFullMode.set(false);
|
||||
// handled internally
|
||||
|
||||
// Reset starting point for continued dragging from compactMode state
|
||||
this.startWidth.set(COLLAPSED_WIDTH);
|
||||
this.startX.set(event.clientX);
|
||||
|
||||
// No visual feedback
|
||||
return;
|
||||
}
|
||||
|
||||
// No visual feedback when approaching collapse threshold
|
||||
|
||||
// Normal resize when fullMode
|
||||
const newWidth = Math.max(minW, Math.min(maxW, potentialWidth));
|
||||
|
||||
this.currentWidth.set(newWidth);
|
||||
} else {
|
||||
// Currently compactMode - check for fullMode threshold
|
||||
const compactModeWidth = COLLAPSED_WIDTH;
|
||||
const draggedWidth = compactModeWidth + deltaX;
|
||||
|
||||
if (draggedWidth > expandTh) {
|
||||
// Auto-switch to fullMode without releasing mouse
|
||||
this.isFullMode.set(true);
|
||||
const newWidth = expandTh + 20; // Start slightly beyond threshold
|
||||
this.currentWidth.set(newWidth);
|
||||
// handled internally
|
||||
|
||||
// Reset starting point for continued dragging from fullMode state
|
||||
this.startWidth.set(this.currentWidth());
|
||||
this.startX.set(event.clientX);
|
||||
|
||||
// No visual feedback
|
||||
return;
|
||||
}
|
||||
|
||||
// Show visual feedback when approaching threshold
|
||||
// No visual feedback when approaching expand threshold
|
||||
}
|
||||
}
|
||||
|
||||
private _handleDragEnd(): void {
|
||||
if (!this.isResizing()) return;
|
||||
|
||||
this.isResizing.set(false);
|
||||
document.removeEventListener('mousemove', this._onDrag);
|
||||
document.removeEventListener('mouseup', this._onDragEnd);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
|
||||
// No visual feedback to reset
|
||||
|
||||
// Save width to localStorage (only save if fullMode)
|
||||
if (this.isFullMode()) {
|
||||
localStorage.setItem(LS.NAV_SIDEBAR_WIDTH, this.currentWidth().toString());
|
||||
}
|
||||
}
|
||||
|
||||
private _enableWidthAnimation(): void {
|
||||
if (this._animateTimeoutId != null) {
|
||||
window.clearTimeout(this._animateTimeoutId);
|
||||
this._animateTimeoutId = null;
|
||||
}
|
||||
this.animateWidth.set(true);
|
||||
// Slightly longer than --transition-duration-m (225ms) to ensure cleanup
|
||||
this._animateTimeoutId = window.setTimeout(() => {
|
||||
this.animateWidth.set(false);
|
||||
this._animateTimeoutId = null;
|
||||
}, 300);
|
||||
}
|
||||
|
||||
private _handleArrowNavigation(event: KeyboardEvent): void {
|
||||
// Get all focusable elements in the nav
|
||||
const focusableElements = this._getFocusableNavElements();
|
||||
|
||||
if (focusableElements.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeElement = document.activeElement as HTMLElement;
|
||||
const currentIndex = focusableElements.indexOf(activeElement);
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
let nextIndex: number;
|
||||
if (currentIndex === -1) {
|
||||
// No nav element focused, focus first element
|
||||
nextIndex = 0;
|
||||
} else if (event.key === 'ArrowDown') {
|
||||
nextIndex = (currentIndex + 1) % focusableElements.length;
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
nextIndex = currentIndex === 0 ? focusableElements.length - 1 : currentIndex - 1;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
focusableElements[nextIndex]?.focus();
|
||||
}
|
||||
|
||||
private _getFocusableNavElements(): HTMLElement[] {
|
||||
const nav = document.querySelector('.nav-sidebar');
|
||||
if (!nav) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get the sidebar toggle button first (if visible)
|
||||
const toggleButton = nav.querySelector('.sidebar-toggle') as HTMLElement;
|
||||
const focusableElements: HTMLElement[] = [];
|
||||
|
||||
if (toggleButton) {
|
||||
const styles = window.getComputedStyle(toggleButton);
|
||||
if (styles.display !== 'none' && styles.visibility !== 'hidden') {
|
||||
toggleButton.setAttribute('tabindex', '0');
|
||||
focusableElements.push(toggleButton);
|
||||
}
|
||||
}
|
||||
|
||||
// Get all nav-link elements (these are the actual clickable items)
|
||||
// Also get any mat-menu-item elements which are used for nav items
|
||||
const selector = '.nav-link, [mat-menu-item]';
|
||||
const allElements = Array.from(nav.querySelectorAll(selector)) as HTMLElement[];
|
||||
|
||||
// Filter to only get elements within the nav-list
|
||||
const navList = nav.querySelector('.nav-list');
|
||||
if (!navList) {
|
||||
return focusableElements; // Return just the toggle if no nav list
|
||||
}
|
||||
|
||||
const navListElements = allElements.filter((el) => {
|
||||
// Make sure the element is inside nav-list
|
||||
const isInNavList = navList.contains(el);
|
||||
|
||||
// Check if it's actually interactive (button or link)
|
||||
const tagName = el.tagName.toLowerCase();
|
||||
const isInteractive = tagName === 'button' || tagName === 'a';
|
||||
|
||||
// Check visibility
|
||||
const styles = window.getComputedStyle(el);
|
||||
const isVisible =
|
||||
styles.display !== 'none' &&
|
||||
styles.visibility !== 'hidden' &&
|
||||
styles.opacity !== '0';
|
||||
|
||||
// Exclude settings buttons and other non-navigation buttons
|
||||
const isSettingsBtn = el.classList.contains('additional-btn');
|
||||
|
||||
return isInNavList && isInteractive && isVisible && !isSettingsBtn;
|
||||
});
|
||||
|
||||
// Set tabindex on nav list elements to make them focusable
|
||||
navListElements.forEach((el) => {
|
||||
if (!el.hasAttribute('tabindex') || el.getAttribute('tabindex') === '-1') {
|
||||
el.setAttribute('tabindex', '0');
|
||||
}
|
||||
});
|
||||
|
||||
// Combine toggle button (if present) with nav list elements
|
||||
const allFocusable = [...focusableElements, ...navListElements];
|
||||
|
||||
return allFocusable;
|
||||
}
|
||||
|
||||
private _handleEscapeKey(event: KeyboardEvent): void {
|
||||
// Unfocus any currently focused navigation element
|
||||
const activeElement = document.activeElement as HTMLElement;
|
||||
const navSidebar = document.querySelector('.nav-sidebar');
|
||||
|
||||
// Check if the currently focused element is within the navigation
|
||||
if (navSidebar && navSidebar.contains(activeElement)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
// Unfocus the navigation element
|
||||
if (activeElement && typeof activeElement.blur === 'function') {
|
||||
activeElement.blur();
|
||||
}
|
||||
|
||||
// Focus the first task if available
|
||||
setTimeout(() => {
|
||||
this._taskService.focusFirstTaskIfVisible();
|
||||
}, 10);
|
||||
}
|
||||
}
|
||||
|
||||
// Public method to focus the first nav entry (for keyboard shortcuts)
|
||||
focusFirstNavEntry(): void {
|
||||
const focusableElements = this._getFocusableNavElements();
|
||||
if (focusableElements.length > 0) {
|
||||
focusableElements[0].focus();
|
||||
}
|
||||
}
|
||||
|
||||
// Inline width binding via template replaces imperative DOM style updates
|
||||
}
|
||||
104
src/app/core-ui/magic-side-nav/magic-side-nav.model.ts
Normal file
104
src/app/core-ui/magic-side-nav/magic-side-nav.model.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import {
|
||||
WorkContextCommon,
|
||||
WorkContextType,
|
||||
} from '../../features/work-context/work-context.model';
|
||||
|
||||
export type NavItem =
|
||||
| NavSeparatorItem
|
||||
| NavWorkContextItem
|
||||
| NavRouteItem
|
||||
| NavHrefItem
|
||||
| NavActionItem
|
||||
| NavGroupItem
|
||||
| NavMenuItem;
|
||||
|
||||
export interface NavBaseItem {
|
||||
id: string;
|
||||
label?: string;
|
||||
icon?: string;
|
||||
svgIcon?: string;
|
||||
}
|
||||
|
||||
export interface NavSeparatorItem extends NavBaseItem {
|
||||
type: 'separator';
|
||||
mtAuto?: true;
|
||||
}
|
||||
|
||||
export interface NavWorkContextItem extends NavBaseItem {
|
||||
type: 'workContext';
|
||||
label: string;
|
||||
icon: string;
|
||||
route: string;
|
||||
workContext: WorkContextCommon;
|
||||
workContextType: WorkContextType;
|
||||
defaultIcon: string;
|
||||
}
|
||||
|
||||
export interface NavRouteItem extends NavBaseItem {
|
||||
type: 'route';
|
||||
label: string;
|
||||
icon: string;
|
||||
route: string;
|
||||
}
|
||||
|
||||
export interface NavHrefItem extends NavBaseItem {
|
||||
type: 'href';
|
||||
label: string;
|
||||
icon: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export interface NavActionItem extends NavBaseItem {
|
||||
type: 'action';
|
||||
label: string;
|
||||
icon: string;
|
||||
action: () => void;
|
||||
}
|
||||
|
||||
export interface NavContextItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
svgIcon?: string;
|
||||
action?: () => void;
|
||||
}
|
||||
|
||||
export interface NavGroupItem extends NavBaseItem {
|
||||
type: 'group';
|
||||
label: string;
|
||||
icon: string;
|
||||
children: NavItem[];
|
||||
additionalButtons?: NavAdditionalButton[];
|
||||
contextMenuItems?: NavContextItem[];
|
||||
action?: () => void; // optional external toggle logic
|
||||
}
|
||||
|
||||
export interface NavMenuItem extends NavBaseItem {
|
||||
type: 'menu';
|
||||
label: string;
|
||||
icon: string;
|
||||
children: NavItem[];
|
||||
}
|
||||
|
||||
export interface NavAdditionalButton {
|
||||
id: string;
|
||||
icon: string;
|
||||
tooltip?: string;
|
||||
action?: () => void;
|
||||
hidden?: boolean;
|
||||
contextMenu?: NavContextItem[];
|
||||
}
|
||||
export interface NavConfig {
|
||||
items: NavItem[];
|
||||
fullModeByDefault: boolean;
|
||||
showLabels: boolean;
|
||||
mobileBreakpoint: number;
|
||||
position: 'left' | 'right';
|
||||
theme: 'light' | 'dark';
|
||||
resizable: boolean;
|
||||
minWidth: number;
|
||||
maxWidth: number;
|
||||
defaultWidth: number;
|
||||
collapseThreshold: number;
|
||||
expandThreshold: number;
|
||||
}
|
||||
189
src/app/core-ui/magic-side-nav/nav-item/nav-item.component.html
Normal file
189
src/app/core-ui/magic-side-nav/nav-item/nav-item.component.html
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
@if (mode() === 'work' && workContext()) {
|
||||
<div
|
||||
class="planner-drag-place-holder-shared"
|
||||
*cdkDragPlaceholder
|
||||
></div>
|
||||
|
||||
<button
|
||||
#routeBtn
|
||||
[routerLink]="[type() === 'TAG' ? 'tag' : 'project', workContext()!.id, 'tasks']"
|
||||
routerLinkActive="active"
|
||||
class="nav-link"
|
||||
[class.has-tasks]="nrOfOpenTasks() > 0"
|
||||
mat-menu-item
|
||||
(click)="clicked.emit()"
|
||||
>
|
||||
<mat-icon
|
||||
class="nav-icon"
|
||||
[class.drag-handle]="defaultIcon() !== 'today' && defaultIcon() !== 'inbox'"
|
||||
>{{ workContext()!.icon || defaultIcon() }}</mat-icon
|
||||
>
|
||||
<span
|
||||
class="nav-label"
|
||||
[class.label-hidden]="!showLabels()"
|
||||
[attr.aria-hidden]="!showLabels() ? 'true' : 'false'"
|
||||
>
|
||||
{{ workContext()!.title }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div class="task-count">{{ nrOfOpenTasks() }}</div>
|
||||
|
||||
<button
|
||||
[style.visibility]="showMoreButton() ? 'visible' : 'hidden'"
|
||||
#settingsBtn
|
||||
class="additional-btn"
|
||||
mat-icon-button
|
||||
>
|
||||
<mat-icon>more_vert</mat-icon>
|
||||
</button>
|
||||
|
||||
<context-menu
|
||||
[contextMenu]="contextMenu"
|
||||
[rightClickTriggerEl]="routeBtn"
|
||||
[leftClickTriggerEl]="settingsBtn"
|
||||
></context-menu>
|
||||
|
||||
<ng-template #contextMenu>
|
||||
<work-context-menu
|
||||
[contextId]="workContext()!.id"
|
||||
[contextType]="type()!"
|
||||
></work-context-menu>
|
||||
</ng-template>
|
||||
} @else {
|
||||
<!-- Container + presentational row unified here -->
|
||||
@if (container() === 'route') {
|
||||
<a
|
||||
[routerLink]="navRoute()"
|
||||
routerLinkActive="active"
|
||||
mat-menu-item
|
||||
class="nav-link"
|
||||
[class.expanded]="expanded()"
|
||||
(click)="clicked.emit()"
|
||||
>
|
||||
@if (svgIcon()) {
|
||||
<mat-icon
|
||||
class="nav-icon"
|
||||
[class.expand-icon]="container() === 'group' && svgIcon() === 'expand_more'"
|
||||
[class.icon--svg]="true"
|
||||
[class.icon--early-on]="svgIcon() === 'early_on'"
|
||||
[svgIcon]="svgIcon()"
|
||||
></mat-icon>
|
||||
} @else if (icon()) {
|
||||
<mat-icon
|
||||
class="nav-icon"
|
||||
[class.expand-icon]="container() === 'group' && icon() === 'expand_more'"
|
||||
>
|
||||
{{ icon() }}
|
||||
</mat-icon>
|
||||
}
|
||||
@if (label()) {
|
||||
<span
|
||||
class="nav-label"
|
||||
[class.label-hidden]="!showLabels()"
|
||||
[attr.aria-hidden]="!showLabels() ? 'true' : 'false'"
|
||||
>
|
||||
{{ label() | translate }}
|
||||
</span>
|
||||
}
|
||||
</a>
|
||||
} @else if (container() === 'href') {
|
||||
<a
|
||||
[href]="navHref()"
|
||||
target="_blank"
|
||||
mat-menu-item
|
||||
rel="noopener noreferrer"
|
||||
class="nav-link"
|
||||
[class.expanded]="expanded()"
|
||||
(click)="clicked.emit()"
|
||||
>
|
||||
@if (svgIcon()) {
|
||||
<mat-icon
|
||||
class="nav-icon"
|
||||
[class.expand-icon]="container() === 'group' && svgIcon() === 'expand_more'"
|
||||
[class.icon--svg]="true"
|
||||
[class.icon--early-on]="svgIcon() === 'early_on'"
|
||||
[svgIcon]="svgIcon()"
|
||||
></mat-icon>
|
||||
} @else if (icon()) {
|
||||
<mat-icon
|
||||
class="nav-icon"
|
||||
[class.expand-icon]="container() === 'group' && icon() === 'expand_more'"
|
||||
>
|
||||
{{ icon() }}
|
||||
</mat-icon>
|
||||
}
|
||||
@if (label()) {
|
||||
<span
|
||||
class="nav-label"
|
||||
[class.label-hidden]="!showLabels()"
|
||||
[attr.aria-hidden]="!showLabels() ? 'true' : 'false'"
|
||||
>
|
||||
{{ label() | translate }}
|
||||
</span>
|
||||
}
|
||||
</a>
|
||||
} @else if (container() === 'action' || container() === 'group') {
|
||||
<button
|
||||
class="nav-link"
|
||||
mat-menu-item
|
||||
[class.expanded]="container() === 'group' && expanded()"
|
||||
[attr.aria-expanded]="container() === 'group' ? expanded() : null"
|
||||
[attr.aria-controls]="container() === 'group' ? ariaControls() : null"
|
||||
[matMenuTriggerFor]="menuTriggerFor()"
|
||||
(click)="clicked.emit()"
|
||||
>
|
||||
@if (svgIcon()) {
|
||||
<mat-icon
|
||||
class="nav-icon"
|
||||
[class.expand-icon]="container() === 'group' && svgIcon() === 'expand_more'"
|
||||
[class.icon--svg]="true"
|
||||
[class.icon--early-on]="svgIcon() === 'early_on'"
|
||||
[svgIcon]="svgIcon()"
|
||||
></mat-icon>
|
||||
} @else if (icon()) {
|
||||
<mat-icon
|
||||
class="nav-icon"
|
||||
[class.expand-icon]="container() === 'group' && icon() === 'expand_more'"
|
||||
>
|
||||
{{ icon() }}
|
||||
</mat-icon>
|
||||
}
|
||||
@if (label()) {
|
||||
<span
|
||||
class="nav-label"
|
||||
[class.label-hidden]="!showLabels()"
|
||||
[attr.aria-hidden]="!showLabels() ? 'true' : 'false'"
|
||||
>
|
||||
{{ label() | translate }}
|
||||
</span>
|
||||
}
|
||||
</button>
|
||||
} @else {
|
||||
<!-- Fallback: raw row (kept for compatibility) -->
|
||||
@if (svgIcon()) {
|
||||
<mat-icon
|
||||
class="nav-icon"
|
||||
[class.expand-icon]="container() === 'group' && svgIcon() === 'expand_more'"
|
||||
[svgIcon]="svgIcon()"
|
||||
></mat-icon>
|
||||
} @else if (icon()) {
|
||||
<mat-icon
|
||||
class="nav-icon"
|
||||
[class.expand-icon]="container() === 'group' && icon() === 'expand_more'"
|
||||
>
|
||||
{{ icon() }}
|
||||
</mat-icon>
|
||||
}
|
||||
|
||||
@if (label()) {
|
||||
<span
|
||||
class="nav-label"
|
||||
[class.label-hidden]="!showLabels()"
|
||||
[attr.aria-hidden]="!showLabels() ? 'true' : 'false'"
|
||||
>
|
||||
{{ label() | translate }}
|
||||
</span>
|
||||
}
|
||||
}
|
||||
}
|
||||
281
src/app/core-ui/magic-side-nav/nav-item/nav-item.component.scss
Normal file
281
src/app/core-ui/magic-side-nav/nav-item/nav-item.component.scss
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
@use 'styles/globals' as *;
|
||||
|
||||
:host {
|
||||
display: flex !important;
|
||||
align-items: stretch;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
color: var(--text-color);
|
||||
|
||||
button:first-of-type {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
// to make space for the task-count
|
||||
&.has-tasks {
|
||||
padding-right: 40px;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
color: var(--text-color);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
// smooth collapse/expand for title
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
transition:
|
||||
opacity var(--transition-duration-m, 0.25s) var(--ani-standard-timing),
|
||||
max-width var(--transition-duration-m, 0.25s) var(--ani-standard-timing),
|
||||
margin var(--transition-duration-m, 0.25s) var(--ani-standard-timing);
|
||||
}
|
||||
|
||||
.ico-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
.additional-btn {
|
||||
flex-shrink: 0;
|
||||
width: 40px !important;
|
||||
min-width: 40px !important;
|
||||
}
|
||||
|
||||
&.isHidden {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
:host.isActiveContext button {
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
:host:hover .task-count {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.additional-btn {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
.task-count {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.task-count {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
font-size: 10px;
|
||||
right: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
// avoid affecting drag handle
|
||||
pointer-events: none;
|
||||
|
||||
@include mq(xs, max) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Active state visuals are handled by .nav-link.active styles
|
||||
|
||||
// Styling when used inside magic-side-nav: rely on global .nav-link/.nav-child-link
|
||||
// Only keep adjustments for the additional button here
|
||||
:host.variant-nav {
|
||||
.g-multi-btn-wrapper {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.additional-btn {
|
||||
color: var(--sidebar-text-secondary);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--sidebar-hover);
|
||||
color: var(--sidebar-text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Smooth label show/hide for presentational rows
|
||||
.nav-label {
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
transition:
|
||||
opacity var(--transition-duration-m, 0.25s) var(--ani-standard-timing),
|
||||
max-width var(--transition-duration-m, 0.25s) var(--ani-standard-timing),
|
||||
margin var(--transition-duration-m, 0.25s) var(--ani-standard-timing);
|
||||
}
|
||||
|
||||
.nav-label.label-hidden,
|
||||
.text.label-hidden {
|
||||
opacity: 0;
|
||||
max-width: 0;
|
||||
margin-left: 0 !important;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
/* Firefox 1.5-26 */
|
||||
position: relative;
|
||||
@include grabCursor();
|
||||
|
||||
&:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: calc(-1 * var(--s));
|
||||
left: calc(-1 * var(--s));
|
||||
right: calc(-1 * var(--s));
|
||||
bottom: calc(-1 * var(--s));
|
||||
}
|
||||
}
|
||||
|
||||
// Standard navigation row styles (simplified to be the only style)
|
||||
.nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s2);
|
||||
padding: var(--s) var(--s2);
|
||||
color: var(--sidebar-text-secondary);
|
||||
text-decoration: none;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: none;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
font-size: 14px;
|
||||
|
||||
&:hover {
|
||||
background: var(--sidebar-hover);
|
||||
color: var(--sidebar-text);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--sidenav-active);
|
||||
color: var(--sidenav-active-text);
|
||||
|
||||
.nav-icon {
|
||||
color: var(--sidenav-active-text);
|
||||
}
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
// Navigation icon and chevron
|
||||
.nav-icon {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--sidebar-text-secondary);
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
// Removed legacy chevron styles; we rotate expand_more icon instead
|
||||
|
||||
// Rotate the expand_more icon for group headers instead of showing a separate chevron
|
||||
.expand-icon {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.expanded .expand-icon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
// Collapsed state adjustments from parent sidebar
|
||||
|
||||
:host-context(.isLightTheme .nav-sidebar.compactMode) {
|
||||
.nav-link {
|
||||
&.active {
|
||||
color: var(--sidenav-active-text);
|
||||
background: var(--sidenav-active);
|
||||
border-radius: 48px;
|
||||
border-bottom: 1px solid var(--separator-color);
|
||||
//border: 1px solid var(--separator-color);
|
||||
border-top: 1px solid var(--separator-color);
|
||||
|
||||
&::after {
|
||||
position: absolute;
|
||||
inset: 4px;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
color: var(--sidenav-active-text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:host-context(.nav-sidebar.compactMode) {
|
||||
.nav-link {
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
|
||||
// Keep dedicated compactMode hover/active feel
|
||||
&:hover {
|
||||
background: var(--sidebar-hover);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--sidenav-active-text);
|
||||
background: var(--sidenav-active);
|
||||
|
||||
&::after {
|
||||
position: absolute;
|
||||
inset: 4px;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
color: var(--sidenav-active-text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
// Hide the overflow menu button entirely when compactMode
|
||||
.additional-btn {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
// Hide Material's menu item text wrapper (keeps icon centered, avoids jump)
|
||||
a:first-of-type ::ng-deep .mat-mdc-menu-item-text,
|
||||
button:first-of-type ::ng-deep .mat-mdc-menu-item-text {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.task-count {
|
||||
display: none;
|
||||
}
|
||||
|
||||
// (no duplicate .nav-link styles here; use global ones)
|
||||
}
|
||||
|
||||
@include mq(xs, max) {
|
||||
:host-context(.nav-sidebar.compactMode) {
|
||||
.nav-link {
|
||||
justify-content: flex-start;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
126
src/app/core-ui/magic-side-nav/nav-item/nav-item.component.ts
Normal file
126
src/app/core-ui/magic-side-nav/nav-item/nav-item.component.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
ElementRef,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { RouterLink, RouterModule } from '@angular/router';
|
||||
|
||||
import {
|
||||
WorkContextCommon,
|
||||
WorkContextType,
|
||||
} from '../../../features/work-context/work-context.model';
|
||||
import { Project } from '../../../features/project/project.model';
|
||||
import { WorkContextMenuComponent } from '../../work-context-menu/work-context-menu.component';
|
||||
import { ContextMenuComponent } from '../../../ui/context-menu/context-menu.component';
|
||||
import { CdkDragPlaceholder } from '@angular/cdk/drag-drop';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatMenuItem, MatMenuModule } from '@angular/material/menu';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { selectAllDoneIds } from '../../../features/tasks/store/task.selectors';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
|
||||
@Component({
|
||||
selector: 'nav-item',
|
||||
imports: [
|
||||
RouterLink,
|
||||
RouterModule,
|
||||
WorkContextMenuComponent,
|
||||
ContextMenuComponent,
|
||||
CdkDragPlaceholder,
|
||||
MatIconButton,
|
||||
MatIcon,
|
||||
MatMenuItem,
|
||||
MatMenuModule,
|
||||
TranslatePipe,
|
||||
],
|
||||
templateUrl: './nav-item.component.html',
|
||||
styleUrl: './nav-item.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: {
|
||||
class: 'g-multi-btn-wrapper',
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'[class.hasTasks]': 'workContextHasTasks()',
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'[class.isActiveContext]': 'isActiveContext()',
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'[class.isHidden]': 'isHidden()',
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'[class.variant-nav]': "variant() === 'nav'",
|
||||
},
|
||||
standalone: true,
|
||||
})
|
||||
export class NavItemComponent {
|
||||
private readonly _store = inject(Store);
|
||||
|
||||
// Mode selection
|
||||
mode = input<'work' | 'row'>('work');
|
||||
|
||||
// Container selection for non-work modes
|
||||
container = input<'route' | 'href' | 'action' | 'group' | null>(null);
|
||||
navRoute = input<string | any[] | undefined>(undefined);
|
||||
navHref = input<string | undefined>(undefined);
|
||||
expanded = input<boolean>(false);
|
||||
ariaControls = input<string | null>(null);
|
||||
|
||||
// Work context inputs
|
||||
workContext = input<WorkContextCommon | null>(null);
|
||||
type = input<WorkContextType | null>(null);
|
||||
defaultIcon = input<string>('folder_special');
|
||||
activeWorkContextId = input<string>('');
|
||||
|
||||
// Variant styling to integrate into magic-side-nav without deep selectors
|
||||
variant = input<'default' | 'nav'>('default');
|
||||
showMoreButton = input<boolean>(true);
|
||||
|
||||
// Presentational row inputs
|
||||
label = input<string | undefined>(undefined);
|
||||
icon = input<string | undefined>(undefined);
|
||||
svgIcon = input<string | undefined>(undefined);
|
||||
showLabels = input<boolean>(true);
|
||||
// Optional: menu trigger for dropdown
|
||||
menuTriggerFor = input<any | null>(null);
|
||||
|
||||
// Events
|
||||
clicked = output<void>();
|
||||
|
||||
allUndoneTaskIds = toSignal(this._store.select(selectAllDoneIds), { initialValue: [] });
|
||||
nrOfOpenTasks = computed<number>(() => {
|
||||
const wc = this.workContext();
|
||||
if (!wc) return 0;
|
||||
const allUndoneTaskIds = this.allUndoneTaskIds();
|
||||
return wc.taskIds.filter((tid) => !allUndoneTaskIds.includes(tid)).length;
|
||||
});
|
||||
|
||||
private readonly _routeBtn = viewChild('routeBtn', { read: ElementRef });
|
||||
|
||||
workContextHasTasks = computed<boolean>(() => {
|
||||
const wc = this.workContext();
|
||||
return !!wc && wc.taskIds.length > 0;
|
||||
});
|
||||
|
||||
isActiveContext = computed<boolean>(() => {
|
||||
const wc = this.workContext();
|
||||
return !!wc && wc.id === this.activeWorkContextId();
|
||||
});
|
||||
|
||||
isHidden = computed<boolean>(() => {
|
||||
const wc = this.workContext();
|
||||
return !!(wc as Project | null)?.isHiddenFromMenu;
|
||||
});
|
||||
|
||||
// removed redundant computed flags: use inputs directly in host bindings
|
||||
|
||||
focus(): void {
|
||||
const btn = this._routeBtn();
|
||||
if (btn) {
|
||||
btn.nativeElement.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
140
src/app/core-ui/magic-side-nav/nav-list/nav-list.component.html
Normal file
140
src/app/core-ui/magic-side-nav/nav-list/nav-list.component.html
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
<div class="g-multi-btn-wrapper">
|
||||
<nav-item
|
||||
[container]="'group'"
|
||||
[expanded]="isExpanded()"
|
||||
[ariaControls]="'group-' + item().id"
|
||||
[icon]="item().icon"
|
||||
[svgIcon]="item().svgIcon"
|
||||
[label]="item().label"
|
||||
[showLabels]="showLabels()"
|
||||
(clicked)="onHeaderClick()"
|
||||
></nav-item>
|
||||
|
||||
@if (showLabels()) {
|
||||
<div class="additional-btns">
|
||||
@for (btn of item().additionalButtons; track btn.id) {
|
||||
@if (!btn.hidden) {
|
||||
@if (btn.id === 'project-visibility') {
|
||||
<button
|
||||
#visibilityBtn
|
||||
class="additional-btn"
|
||||
mat-icon-button
|
||||
[matTooltip]="btn.tooltip | translate"
|
||||
[matMenuTriggerFor]="visibilityMenu"
|
||||
>
|
||||
<mat-icon>{{ btn.icon }}</mat-icon>
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
class="additional-btn"
|
||||
mat-icon-button
|
||||
[matTooltip]="btn.tooltip | translate"
|
||||
(click)="btn.action?.()"
|
||||
>
|
||||
<mat-icon>{{ btn.icon }}</mat-icon>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (item().children && item().children.length > 0 && isExpanded()) {
|
||||
<ul
|
||||
[@expandFade]
|
||||
[@standardList]="item().children.length"
|
||||
class="nav-children"
|
||||
[attr.id]="'group-' + item().id"
|
||||
role="list"
|
||||
cdkDropList
|
||||
[cdkDropListSortingDisabled]="false"
|
||||
[cdkDropListAutoScrollDisabled]="true"
|
||||
(cdkDropListDropped)="onDrop($event)"
|
||||
>
|
||||
@for (child of item().children; track child.id) {
|
||||
@if (child.type === 'workContext') {
|
||||
<li
|
||||
class="nav-child-item"
|
||||
[class.draggable]="
|
||||
child.defaultIcon !== 'today' && child.defaultIcon !== 'inbox'
|
||||
"
|
||||
role="listitem"
|
||||
cdkDrag
|
||||
[cdkDragData]="child"
|
||||
[cdkDragStartDelay]="IS_TOUCH_PRIMARY ? DRAG_DELAY_FOR_TOUCH_LONGER : 0"
|
||||
>
|
||||
<nav-item
|
||||
[workContext]="child.workContext"
|
||||
[type]="child.workContextType"
|
||||
[defaultIcon]="child.defaultIcon"
|
||||
[activeWorkContextId]="activeWorkContextId() || ''"
|
||||
[variant]="'nav'"
|
||||
[showLabels]="true"
|
||||
[showMoreButton]="true"
|
||||
(clicked)="onChildClick(child)"
|
||||
></nav-item>
|
||||
</li>
|
||||
} @else {
|
||||
<li
|
||||
class="nav-child-item"
|
||||
role="listitem"
|
||||
>
|
||||
@switch (child.type) {
|
||||
@case ('route') {
|
||||
<nav-item
|
||||
[container]="'route'"
|
||||
[navRoute]="child.route"
|
||||
[icon]="child.icon"
|
||||
[svgIcon]="child.svgIcon"
|
||||
[label]="child.label"
|
||||
[showLabels]="true"
|
||||
(clicked)="onChildClick(child)"
|
||||
></nav-item>
|
||||
}
|
||||
@case ('href') {
|
||||
<nav-item
|
||||
[container]="'href'"
|
||||
[navHref]="child.href"
|
||||
[icon]="child.icon"
|
||||
[svgIcon]="child.svgIcon"
|
||||
[label]="child.label"
|
||||
[showLabels]="true"
|
||||
(clicked)="onChildClick(child)"
|
||||
></nav-item>
|
||||
}
|
||||
@case ('action') {
|
||||
<nav-item
|
||||
[container]="'action'"
|
||||
[icon]="child.icon"
|
||||
[svgIcon]="child.svgIcon"
|
||||
[label]="child.label"
|
||||
[showLabels]="true"
|
||||
(clicked)="onChildClick(child)"
|
||||
></nav-item>
|
||||
}
|
||||
}
|
||||
</li>
|
||||
}
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
|
||||
<!-- Project Visibility Menu -->
|
||||
<!--@if (item().id === 'projects') {-->
|
||||
<mat-menu #visibilityMenu="matMenu">
|
||||
@for (project of allProjectsExceptInbox(); track project.id) {
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="toggleProjectVisibility(project.id)"
|
||||
>
|
||||
@if (!project.isHiddenFromMenu) {
|
||||
<mat-icon>visibility</mat-icon>
|
||||
} @else {
|
||||
<mat-icon>visibility_off</mat-icon>
|
||||
}
|
||||
<span>{{ project.title }}</span>
|
||||
</button>
|
||||
}
|
||||
</mat-menu>
|
||||
<!--}-->
|
||||
133
src/app/core-ui/magic-side-nav/nav-list/nav-list.component.scss
Normal file
133
src/app/core-ui/magic-side-nav/nav-list/nav-list.component.scss
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
:host {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.g-multi-btn-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
|
||||
// Hide additional buttons by default on desktop
|
||||
.additional-btns {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
// Show additional buttons on hover (desktop)
|
||||
&:hover .additional-btns {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
// Always show buttons on mobile/touch devices
|
||||
@media (hover: none) {
|
||||
.additional-btns {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.additional-btns {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.additional-btn {
|
||||
flex-shrink: 0;
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
min-width: 32px !important;
|
||||
padding: 4px !important;
|
||||
margin-left: var(--s);
|
||||
color: var(--sidebar-text-secondary);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0 !important; // Override Material Design rounded corners
|
||||
cursor: pointer;
|
||||
|
||||
// Override Material Design button styles
|
||||
&.mat-mdc-icon-button {
|
||||
--mdc-icon-button-state-layer-size: 32px;
|
||||
--mdc-icon-button-icon-size: 18px;
|
||||
border-radius: 0 !important;
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
padding: 4px !important;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--sidebar-hover);
|
||||
color: var(--sidebar-text);
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-children {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 4px 0 0 0;
|
||||
// Angular animations handle expand/collapse - no CSS transitions needed
|
||||
}
|
||||
|
||||
.nav-child-item {
|
||||
&.draggable {
|
||||
cursor: grab;
|
||||
// Remove default transitions for better performance
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
&.cdk-drag-animating {
|
||||
transition: transform 200ms cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
}
|
||||
}
|
||||
|
||||
&.cdk-drag-preview {
|
||||
box-sizing: border-box;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||
transform: rotate(2deg);
|
||||
opacity: 0.9;
|
||||
// Use GPU acceleration
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
&.cdk-drag-placeholder {
|
||||
opacity: 0.4;
|
||||
background: var(--sidebar-hover);
|
||||
border-radius: 4px;
|
||||
// Minimize repaint
|
||||
transform: translateZ(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Drag performance optimizations - keep these for smooth drag operations
|
||||
.cdk-drop-list-dragging .nav-child-item:not(.cdk-drag-placeholder) {
|
||||
transition: transform 200ms cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
// CompactMode state behaviors inherited from parent sidebar
|
||||
:host-context(.nav-sidebar.compactMode) {
|
||||
.g-multi-btn-wrapper {
|
||||
justify-content: center;
|
||||
|
||||
.additional-btn {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Angular animations will handle expand/collapse behavior
|
||||
// The service handles filtering to show only active items when collapsed
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
import { ChangeDetectionStrategy, Component, inject, input, output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { CdkDrag, CdkDragDrop, CdkDropList } from '@angular/cdk/drag-drop';
|
||||
import { NavItemComponent } from '../nav-item/nav-item.component';
|
||||
import { NavGroupItem, NavItem, NavWorkContextItem } from '../magic-side-nav.model';
|
||||
import { DRAG_DELAY_FOR_TOUCH_LONGER } from '../../../app.constants';
|
||||
import { IS_TOUCH_PRIMARY } from '../../../util/is-mouse-primary';
|
||||
import { standardListAnimation } from '../../../ui/animations/standard-list.ani';
|
||||
import { expandFadeAnimation } from '../../../ui/animations/expand.ani';
|
||||
import { MagicNavConfigService } from '../magic-nav-config.service';
|
||||
|
||||
@Component({
|
||||
selector: 'nav-list',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatIcon,
|
||||
MatIconButton,
|
||||
MatTooltip,
|
||||
MatMenuModule,
|
||||
TranslatePipe,
|
||||
CdkDropList,
|
||||
CdkDrag,
|
||||
NavItemComponent,
|
||||
],
|
||||
templateUrl: './nav-list.component.html',
|
||||
styleUrl: './nav-list.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
animations: [standardListAnimation, expandFadeAnimation],
|
||||
})
|
||||
export class NavSectionComponent {
|
||||
private readonly _navConfigService = inject(MagicNavConfigService);
|
||||
|
||||
item = input.required<NavGroupItem>();
|
||||
showLabels = input<boolean>(true);
|
||||
isExpanded = input<boolean>(false);
|
||||
activeWorkContextId = input<string | null>(null);
|
||||
|
||||
itemClick = output<NavItem>();
|
||||
dragDrop = output<{
|
||||
items: NavWorkContextItem[];
|
||||
event: CdkDragDrop<string, string, NavWorkContextItem>;
|
||||
}>();
|
||||
|
||||
readonly IS_TOUCH_PRIMARY = IS_TOUCH_PRIMARY;
|
||||
readonly DRAG_DELAY_FOR_TOUCH_LONGER = DRAG_DELAY_FOR_TOUCH_LONGER;
|
||||
|
||||
// Access to service methods and data for visibility menu
|
||||
readonly allProjectsExceptInbox = this._navConfigService.allProjectsExceptInbox;
|
||||
|
||||
onHeaderClick(): void {
|
||||
this.itemClick.emit(this.item());
|
||||
}
|
||||
|
||||
onChildClick(child: NavItem): void {
|
||||
this.itemClick.emit(child);
|
||||
}
|
||||
|
||||
toggleProjectVisibility(projectId: string): void {
|
||||
this._navConfigService.toggleProjectVisibility(projectId);
|
||||
}
|
||||
|
||||
onDrop(event: CdkDragDrop<string, string, NavWorkContextItem>): void {
|
||||
// Early exit if no actual movement
|
||||
if (
|
||||
event.previousContainer !== event.container ||
|
||||
event.currentIndex === event.previousIndex
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter work context items once
|
||||
const workContextItems =
|
||||
this.item().children?.filter(
|
||||
(child): child is NavWorkContextItem => child.type === 'workContext',
|
||||
) || [];
|
||||
|
||||
// Only emit if there are actually work context items to reorder
|
||||
if (workContextItems.length > 0) {
|
||||
this.dragDrop.emit({ items: workContextItems, event });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
<li
|
||||
class="nav-item"
|
||||
role="listitem"
|
||||
>
|
||||
<div class="g-multi-btn-wrapper">
|
||||
<nav-item
|
||||
[container]="'group'"
|
||||
[icon]="item().icon"
|
||||
[svgIcon]="item().svgIcon"
|
||||
[label]="item().label"
|
||||
[showLabels]="showLabels()"
|
||||
[menuTriggerFor]="menuRef"
|
||||
></nav-item>
|
||||
</div>
|
||||
|
||||
<mat-menu #menuRef="matMenu">
|
||||
@for (child of item().children; track child.id) {
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="onChildClick(child)"
|
||||
>
|
||||
@if (child['svgIcon']) {
|
||||
<mat-icon
|
||||
class="nav-icon"
|
||||
[svgIcon]="child['svgIcon']"
|
||||
></mat-icon>
|
||||
} @else {
|
||||
<mat-icon class="nav-icon">{{ child.icon }}</mat-icon>
|
||||
}
|
||||
<span>{{ child.label | translate }}</span>
|
||||
</button>
|
||||
}
|
||||
</mat-menu>
|
||||
</li>
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
/* Reuse existing nav-link and nav-icon classes from side nav styles */
|
||||
:host {
|
||||
display: contents;
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { NavItem, NavMenuItem } from '../magic-side-nav.model';
|
||||
import { NavItemComponent } from '../nav-item/nav-item.component';
|
||||
|
||||
@Component({
|
||||
selector: 'nav-mat-menu',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatMenuModule,
|
||||
MatIcon,
|
||||
RouterModule,
|
||||
TranslatePipe,
|
||||
NavItemComponent,
|
||||
],
|
||||
templateUrl: './nav-mat-menu.component.html',
|
||||
styleUrl: './nav-mat-menu.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class NavMatMenuComponent {
|
||||
item = input.required<NavMenuItem>();
|
||||
showLabels = input<boolean>(true);
|
||||
|
||||
itemClick = output<NavItem>();
|
||||
|
||||
onChildClick(child: NavItem): void {
|
||||
this.itemClick.emit(child);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +1,4 @@
|
|||
<div class="wrapper">
|
||||
@if (!isNavAlwaysVisible()) {
|
||||
<button
|
||||
(click)="layoutService.toggleSideNav()"
|
||||
class="burger-trigger tour-burgerTrigger"
|
||||
mat-icon-button
|
||||
>
|
||||
<mat-icon>menu</mat-icon>
|
||||
</button>
|
||||
}
|
||||
|
||||
<work-context-title
|
||||
[title]="activeWorkContextTitle()"
|
||||
[activeWorkContextTypeAndId]="activeWorkContextTypeAndId()"
|
||||
|
|
|
|||
|
|
@ -52,23 +52,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
.current-work-context-title {
|
||||
font-size: 18px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
cursor: pointer;
|
||||
border-radius: var(--card-border-radius);
|
||||
padding: var(--s) var(--s2) var(--s) var(--s);
|
||||
@include mq(xs) {
|
||||
padding-right: var(--s);
|
||||
}
|
||||
&:focus {
|
||||
outline: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.project-settings-btn {
|
||||
display: none;
|
||||
@include mq(xs) {
|
||||
|
|
@ -268,7 +251,7 @@ button.isActive2 {
|
|||
&::after {
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 -2px 3px var(--separator-alpha);
|
||||
background: var(--sidebar-bg);
|
||||
background: var(--sidenav-bg);
|
||||
content: '';
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
|
|
|
|||
|
|
@ -146,7 +146,6 @@ export class MainHeaderComponent implements OnDestroy {
|
|||
this.workContextService.activeWorkContextTypeAndId$,
|
||||
);
|
||||
activeWorkContextTitle = toSignal(this.workContextService.activeWorkContextTitle$);
|
||||
isNavAlwaysVisible = computed(() => this.layoutService.isNavAlwaysVisible());
|
||||
currentTask = toSignal(this.taskService.currentTask$);
|
||||
currentTaskId = this.taskService.currentTaskId;
|
||||
pomodoroIsEnabled = toSignal(this.pomodoroService.isEnabled$);
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@
|
|||
|
||||
// Active button styles
|
||||
button.active {
|
||||
background-color: var(--sidebar-bg);
|
||||
background-color: var(--sidenav-bg);
|
||||
|
||||
&.isCustomized {
|
||||
background-color: var(--c-accent);
|
||||
|
|
|
|||
|
|
@ -72,8 +72,10 @@ import { map } from 'rxjs/operators';
|
|||
cursor: pointer;
|
||||
border-radius: var(--card-border-radius);
|
||||
padding: var(--s) var(--s2) var(--s) var(--s);
|
||||
padding-left: 54px;
|
||||
|
||||
@media (min-width: 600px) {
|
||||
padding-left: 0;
|
||||
padding-right: var(--s);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -132,8 +132,8 @@ export class ShortcutService {
|
|||
} else if (checkKeyCombo(ev, keys.showSearchBar)) {
|
||||
this._router.navigate(['/search']);
|
||||
ev.preventDefault();
|
||||
} else if (checkKeyCombo(ev, keys.toggleSideNav)) {
|
||||
this._layoutService.toggleSideNav();
|
||||
} else if (checkKeyCombo(ev, keys.focusSideNav)) {
|
||||
this._focusSideNav();
|
||||
ev.preventDefault();
|
||||
} else if (checkKeyCombo(ev, keys.addNewTask)) {
|
||||
this._layoutService.showAddTaskBar();
|
||||
|
|
@ -194,4 +194,37 @@ export class ShortcutService {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _focusSideNav(): void {
|
||||
console.log('FocusSideNav called');
|
||||
|
||||
// Very simple approach - just find the first nav-link and focus it
|
||||
const firstNavLink = document.querySelector('.nav-sidebar .nav-link') as HTMLElement;
|
||||
console.log('First nav link found:', !!firstNavLink, firstNavLink);
|
||||
|
||||
if (firstNavLink) {
|
||||
// Make sure it's focusable
|
||||
firstNavLink.setAttribute('tabindex', '0');
|
||||
|
||||
// Focus with a small delay to ensure DOM is ready
|
||||
setTimeout(() => {
|
||||
firstNavLink.focus();
|
||||
console.log('Focused element. Active element is now:', document.activeElement);
|
||||
console.log('Focus successful:', document.activeElement === firstNavLink);
|
||||
}, 10);
|
||||
} else {
|
||||
// Fallback: try to focus the toggle button
|
||||
const toggleBtn = document.querySelector(
|
||||
'.nav-sidebar .sidebar-toggle',
|
||||
) as HTMLElement;
|
||||
console.log('Toggle button found:', !!toggleBtn, toggleBtn);
|
||||
if (toggleBtn) {
|
||||
toggleBtn.setAttribute('tabindex', '0');
|
||||
setTimeout(() => {
|
||||
toggleBtn.focus();
|
||||
console.log('Focused toggle. Active element is now:', document.activeElement);
|
||||
}, 10);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,43 +0,0 @@
|
|||
<div
|
||||
class="planner-drag-place-holder-shared"
|
||||
*cdkDragPlaceholder
|
||||
></div>
|
||||
|
||||
<div
|
||||
[style.background]="workContext().theme.primary"
|
||||
class="color-bar"
|
||||
></div>
|
||||
|
||||
<button
|
||||
#routeBtn
|
||||
[routerLink]="[type() === 'TAG' ? 'tag' : 'project', workContext().id, 'tasks']"
|
||||
routerLinkActive="isActiveRoute"
|
||||
mat-menu-item
|
||||
>
|
||||
<span class="badge">{{ nrOfOpenTasks() }}</span>
|
||||
<mat-icon class="drag-handle">{{ workContext().icon || defaultIcon() }}</mat-icon>
|
||||
<span class="text">{{ workContext().title }}</span>
|
||||
</button>
|
||||
|
||||
<!--[matMenuTriggerFor]="contextMenuEl"-->
|
||||
|
||||
<button
|
||||
#settingsBtn
|
||||
class="additional-btn"
|
||||
mat-icon-button
|
||||
>
|
||||
<mat-icon>more_vert</mat-icon>
|
||||
</button>
|
||||
|
||||
<context-menu
|
||||
[contextMenu]="contextMenu"
|
||||
[rightClickTriggerEl]="routeBtn"
|
||||
[leftClickTriggerEl]="settingsBtn"
|
||||
></context-menu>
|
||||
|
||||
<ng-template #contextMenu>
|
||||
<work-context-menu
|
||||
[contextId]="workContext().id"
|
||||
[contextType]="type()"
|
||||
></work-context-menu
|
||||
></ng-template>
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
@use '../../../../styles/_globals.scss' as *;
|
||||
|
||||
:host {
|
||||
button .badge {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.hasTasks {
|
||||
button .badge {
|
||||
display: block;
|
||||
z-index: 10;
|
||||
position: absolute;
|
||||
line-height: 1;
|
||||
right: 100%;
|
||||
text-align: center;
|
||||
bottom: 6px;
|
||||
font-size: 10px;
|
||||
min-width: 18px;
|
||||
padding: 0 4px 0;
|
||||
border: 1px solid var(--extra-border-color);
|
||||
border-radius: 12px;
|
||||
margin-right: -49px;
|
||||
|
||||
// avoid affecting drag handle
|
||||
pointer-events: none;
|
||||
|
||||
border-color: var(--extra-border-color);
|
||||
background: var(--bg-lighter);
|
||||
}
|
||||
}
|
||||
|
||||
&.isHidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
// color bar left styles
|
||||
.color-bar,
|
||||
&.isActiveRoute:before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: var(--s-half);
|
||||
background-color: var(--c-primary);
|
||||
}
|
||||
|
||||
.color-bar {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&.isActiveContext,
|
||||
&:focus,
|
||||
&:hover {
|
||||
.color-bar {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:host-context(.cdk-drop-list-dragging) {
|
||||
.color-bar {
|
||||
opacity: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
:host.isActiveContext button {
|
||||
font-weight: normal;
|
||||
|
||||
&.isActiveRoute {
|
||||
font-weight: bold;
|
||||
color: var(--palette-primary-800);
|
||||
|
||||
mat-icon {
|
||||
color: var(--palette-primary-800);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
body.isDarkTheme :host.isActiveContext button.isActiveRoute {
|
||||
color: var(--c-primary);
|
||||
|
||||
mat-icon {
|
||||
color: var(--c-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
/* Firefox 1.5-26 */
|
||||
position: relative;
|
||||
@include grabCursor();
|
||||
|
||||
&:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: calc(-1 * var(--s));
|
||||
left: calc(-1 * var(--s));
|
||||
right: calc(-1 * var(--s));
|
||||
bottom: calc(-1 * var(--s));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
// import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
//
|
||||
// import { SideNavItemComponent } from './side-nav-item.component';
|
||||
//
|
||||
// describe('SideNavItemComponent', () => {
|
||||
// let component: SideNavItemComponent;
|
||||
// let fixture: ComponentFixture<SideNavItemComponent>;
|
||||
//
|
||||
// beforeEach(async () => {
|
||||
// await TestBed.configureTestingModule({
|
||||
// imports: [SideNavItemComponent]
|
||||
// })
|
||||
// .compileComponents();
|
||||
//
|
||||
// fixture = TestBed.createComponent(SideNavItemComponent);
|
||||
// component = fixture.componentInstance;
|
||||
// fixture.detectChanges();
|
||||
// });
|
||||
//
|
||||
// it('should create', () => {
|
||||
// expect(component).toBeTruthy();
|
||||
// });
|
||||
// });
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
ElementRef,
|
||||
HostBinding,
|
||||
inject,
|
||||
input,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { RouterLink, RouterModule } from '@angular/router';
|
||||
|
||||
import {
|
||||
WorkContextCommon,
|
||||
WorkContextType,
|
||||
} from '../../../features/work-context/work-context.model';
|
||||
import { Project } from '../../../features/project/project.model';
|
||||
import { WorkContextMenuComponent } from '../../work-context-menu/work-context-menu.component';
|
||||
import { ContextMenuComponent } from '../../../ui/context-menu/context-menu.component';
|
||||
import { CdkDragPlaceholder } from '@angular/cdk/drag-drop';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatMenuItem } from '@angular/material/menu';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { selectAllDoneIds } from '../../../features/tasks/store/task.selectors';
|
||||
import { Store } from '@ngrx/store';
|
||||
|
||||
@Component({
|
||||
selector: 'side-nav-item',
|
||||
imports: [
|
||||
RouterLink,
|
||||
RouterModule,
|
||||
WorkContextMenuComponent,
|
||||
ContextMenuComponent,
|
||||
CdkDragPlaceholder,
|
||||
MatIconButton,
|
||||
MatIcon,
|
||||
MatMenuItem,
|
||||
],
|
||||
templateUrl: './side-nav-item.component.html',
|
||||
styleUrl: './side-nav-item.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: { class: 'g-multi-btn-wrapper' },
|
||||
standalone: true,
|
||||
})
|
||||
export class SideNavItemComponent {
|
||||
private readonly _store = inject(Store);
|
||||
|
||||
workContext = input.required<WorkContextCommon>();
|
||||
type = input.required<WorkContextType>();
|
||||
defaultIcon = input.required<string>();
|
||||
activeWorkContextId = input.required<string>();
|
||||
|
||||
allUndoneTaskIds = toSignal(this._store.select(selectAllDoneIds), { initialValue: [] });
|
||||
nrOfOpenTasks = computed<number>(() => {
|
||||
// const allUndoneTasks
|
||||
const allUndoneTaskIds = this.allUndoneTaskIds();
|
||||
return this.workContext().taskIds.filter((tid) => !allUndoneTaskIds.includes(tid))
|
||||
.length;
|
||||
});
|
||||
|
||||
readonly routeBtn = viewChild.required('routeBtn', { read: ElementRef });
|
||||
|
||||
@HostBinding('class.hasTasks')
|
||||
get workContextHasTasks(): boolean {
|
||||
return this.workContext().taskIds.length > 0;
|
||||
}
|
||||
|
||||
@HostBinding('class.isActiveContext')
|
||||
get isActiveContext(): boolean {
|
||||
return this.workContext().id === this.activeWorkContextId();
|
||||
}
|
||||
|
||||
@HostBinding('class.isHidden')
|
||||
get isHidden(): boolean {
|
||||
return !!(this.workContext() as Project)?.isHiddenFromMenu;
|
||||
}
|
||||
|
||||
focus(): void {
|
||||
this.routeBtn().nativeElement.focus();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,309 +0,0 @@
|
|||
<section class="main">
|
||||
@if (workContextService.mainWorkContext$ | async; as mainContext) {
|
||||
<side-nav-item
|
||||
#menuEntry
|
||||
[type]="WorkContextType.TAG"
|
||||
[defaultIcon]="mainContext.icon"
|
||||
[workContext]="mainContext"
|
||||
[activeWorkContextId]="activeWorkContextId"
|
||||
></side-nav-item>
|
||||
}
|
||||
@let inboxContext = workContextService.inboxWorkContext$ | async;
|
||||
@if (inboxContext) {
|
||||
<side-nav-item
|
||||
#menuEntry
|
||||
[type]="WorkContextType.PROJECT"
|
||||
[defaultIcon]="inboxContext.icon"
|
||||
[workContext]="inboxContext"
|
||||
[activeWorkContextId]="activeWorkContextId"
|
||||
></side-nav-item>
|
||||
}
|
||||
|
||||
<button
|
||||
#menuEntry
|
||||
class="route-link"
|
||||
mat-menu-item
|
||||
routerLink="schedule"
|
||||
routerLinkActive="isActiveRoute"
|
||||
>
|
||||
<mat-icon svgIcon="early_on"></mat-icon>
|
||||
<span class="text">{{ T.MH.SCHEDULE | translate }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
#menuEntry
|
||||
class="route-link"
|
||||
mat-menu-item
|
||||
routerLink="planner"
|
||||
routerLinkActive="isActiveRoute"
|
||||
>
|
||||
<mat-icon>edit_calendar</mat-icon>
|
||||
<span class="text">{{ T.MH.PLANNER | translate }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
#menuEntry
|
||||
class="route-link"
|
||||
mat-menu-item
|
||||
routerLink="boards"
|
||||
routerLinkActive="isActiveRoute"
|
||||
>
|
||||
<mat-icon>grid_view</mat-icon>
|
||||
<span class="text">{{ T.MH.BOARDS | translate }}</span>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
@if (nonHiddenProjects(); as projectList) {
|
||||
<section class="projects tour-projects">
|
||||
<div class="g-multi-btn-wrapper e2e-projects-btn">
|
||||
<button
|
||||
#menuEntry
|
||||
#projectExpandBtn
|
||||
(click)="toggleExpandProjects()"
|
||||
(keydown)="toggleExpandProjectsLeftRight($event)"
|
||||
[class.isExpanded]="isProjectsExpanded()"
|
||||
class="expand-btn"
|
||||
mat-menu-item
|
||||
>
|
||||
<mat-icon>expand_more</mat-icon>
|
||||
<span class="title">{{ T.MH.PROJECTS | translate }}</span>
|
||||
</button>
|
||||
<!-- <button-->
|
||||
<!-- #projectVisibilityBtn-->
|
||||
<!-- mat-icon-button-->
|
||||
<!-- class="additional-btn"-->
|
||||
<!-- >-->
|
||||
<!-- <mat-icon>visibility</mat-icon>-->
|
||||
<!-- </button>-->
|
||||
<button
|
||||
[style.display]="allNonInboxProjects().length ? '' : 'none'"
|
||||
class="additional-btn"
|
||||
#projectExpandBtn2
|
||||
mat-icon-button
|
||||
>
|
||||
<mat-icon>visibility_off</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
(click)="addProject()"
|
||||
class="additional-btn e2e-add-project-btn"
|
||||
mat-icon-button
|
||||
[matTooltip]="T.MH.CREATE_PROJECT | translate"
|
||||
>
|
||||
<mat-icon>add</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
<!-- [leftClickTriggerEl]="projectVisibilityBtn"-->
|
||||
<context-menu
|
||||
[contextMenu]="contextMenu"
|
||||
[leftClickTriggerEl]="projectExpandBtn2"
|
||||
[rightClickTriggerEl]="projectExpandBtn"
|
||||
></context-menu>
|
||||
<ng-template #contextMenu>
|
||||
@for (project of allNonInboxProjects(); track project.id) {
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="toggleProjectVisibility(project)"
|
||||
>
|
||||
@if (!project.isHiddenFromMenu) {
|
||||
<mat-icon>visibility</mat-icon>
|
||||
}
|
||||
@if (project.isHiddenFromMenu) {
|
||||
<mat-icon>visibility_off</mat-icon>
|
||||
}
|
||||
{{ project.title }}
|
||||
</button>
|
||||
}
|
||||
</ng-template>
|
||||
<div
|
||||
[@standardList]="projectList?.length"
|
||||
cdkDropList
|
||||
(cdkDropListDropped)="dropOnProjectList(projectList, $event)"
|
||||
>
|
||||
@for (project of projectList; track project.id) {
|
||||
<side-nav-item
|
||||
#menuEntry
|
||||
cdkDrag
|
||||
[cdkDragData]="project"
|
||||
[cdkDragStartDelay]="IS_TOUCH_PRIMARY ? DRAG_DELAY_FOR_TOUCH_LONGER : 0"
|
||||
(keydown)="checkFocusProject($event)"
|
||||
[workContext]="project"
|
||||
[type]="WorkContextType.PROJECT"
|
||||
[defaultIcon]="'folder_special'"
|
||||
[activeWorkContextId]="activeWorkContextId"
|
||||
></side-nav-item>
|
||||
}
|
||||
</div>
|
||||
@if (!projectList.length && isProjectsExpanded()) {
|
||||
<div class="no-tags-info">{{ T.MH.NO_PROJECT_INFO | translate }}</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
@if (tagList(); as tagList) {
|
||||
<section class="tags">
|
||||
<button
|
||||
#menuEntry
|
||||
#tagExpandBtn
|
||||
(click)="toggleExpandTags()"
|
||||
(keydown)="toggleExpandTagsLeftRight($event)"
|
||||
[class.isExpanded]="isTagsExpanded()"
|
||||
class="expand-btn"
|
||||
mat-menu-item
|
||||
>
|
||||
<span class="title">{{ T.MH.TAGS | translate }}</span>
|
||||
<mat-icon>expand_more</mat-icon>
|
||||
</button>
|
||||
<div
|
||||
[@standardList]="tagList?.length"
|
||||
cdkDropList
|
||||
(cdkDropListDropped)="dropOnTagList(tagList, $event)"
|
||||
>
|
||||
@for (tag of tagList; track tag.id) {
|
||||
<side-nav-item
|
||||
#menuEntry
|
||||
cdkDrag
|
||||
[cdkDragData]="tag"
|
||||
[cdkDragStartDelay]="IS_TOUCH_PRIMARY ? DRAG_DELAY_FOR_TOUCH_LONGER : 0"
|
||||
(keydown)="checkFocusTag($event)"
|
||||
[workContext]="tag"
|
||||
[type]="WorkContextType.TAG"
|
||||
[defaultIcon]="'label'"
|
||||
[activeWorkContextId]="activeWorkContextId"
|
||||
></side-nav-item>
|
||||
}
|
||||
</div>
|
||||
@if (!tagList.length && isTagsExpanded()) {
|
||||
<div class="no-tags-info">{{ T.MH.NO_TAG_INFO | translate }}</div>
|
||||
}
|
||||
<!-- <button (click)="addTag()"-->
|
||||
<!-- *ngIf="isTagsExpanded"-->
|
||||
<!-- #menuEntry mat-menu-item>-->
|
||||
<!-- <mat-icon>add</mat-icon>-->
|
||||
<!-- {{T.MH.CREATE_TAG|translate}}-->
|
||||
<!-- </button>-->
|
||||
</section>
|
||||
}
|
||||
|
||||
<!--<section class="other-task-level-section">-->
|
||||
<!--</section>-->
|
||||
|
||||
<section class="app">
|
||||
<button
|
||||
#menuEntry
|
||||
class="route-link"
|
||||
mat-menu-item
|
||||
routerLink="search"
|
||||
routerLinkActive="isActiveRoute"
|
||||
>
|
||||
<mat-icon>search</mat-icon>
|
||||
<span class="text">{{ T.MH.SEARCH | translate }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
#menuEntry
|
||||
class="route-link"
|
||||
mat-menu-item
|
||||
routerLink="scheduled-list"
|
||||
routerLinkActive="isActiveRoute"
|
||||
>
|
||||
<!-- <mat-icon>history</mat-icon>-->
|
||||
<mat-icon>list</mat-icon>
|
||||
<span class="text">{{ T.MH.ALL_PLANNED_LIST | translate }}</span>
|
||||
</button>
|
||||
<button
|
||||
#menuEntry
|
||||
[mat-menu-trigger-for]="helpMenu"
|
||||
class="route-link"
|
||||
mat-menu-item
|
||||
>
|
||||
<mat-icon>help_center</mat-icon>
|
||||
<span class="text">{{ T.MH.HELP | translate }}</span>
|
||||
</button>
|
||||
|
||||
<mat-menu #helpMenu="matMenu">
|
||||
<ng-template matMenuContent>
|
||||
<a
|
||||
mat-menu-item
|
||||
href="https://github.com/johannesjo/super-productivity/blob/master/README.md#question-how-to-use-it"
|
||||
target="_blank"
|
||||
>
|
||||
<mat-icon>help_center</mat-icon>
|
||||
<span class="text">{{ T.MH.HM.GET_HELP_ONLINE | translate }}</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
class="route-link"
|
||||
mat-menu-item
|
||||
[href]="getGithubErrorUrl()"
|
||||
target="_blank"
|
||||
>
|
||||
<mat-icon>bug_report</mat-icon>
|
||||
<span class="text">{{ T.MH.HM.REPORT_A_PROBLEM | translate }}</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
mat-menu-item
|
||||
href="https://github.com/johannesjo/super-productivity/blob/master/CONTRIBUTING.md"
|
||||
target="_blank"
|
||||
>
|
||||
<mat-icon>volunteer_activism</mat-icon>
|
||||
<span class="text">{{ T.MH.HM.CONTRIBUTE | translate }}</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
mat-menu-item
|
||||
href="https://www.reddit.com/r/superProductivity/"
|
||||
target="_blank"
|
||||
>
|
||||
<mat-icon>forum</mat-icon>
|
||||
<span class="text">{{ T.MH.HM.REDDIT_COMMUNITY | translate }}</span>
|
||||
</a>
|
||||
|
||||
<button
|
||||
(click)="startTour(TourId.Welcome)"
|
||||
mat-menu-item
|
||||
>
|
||||
<mat-icon>directions</mat-icon>
|
||||
<span class="text">{{ T.MH.HM.START_WELCOME | translate }}</span>
|
||||
</button>
|
||||
@if (IS_MOUSE_PRIMARY) {
|
||||
<button
|
||||
(click)="startTour(TourId.KeyboardNav)"
|
||||
mat-menu-item
|
||||
>
|
||||
<mat-icon>directions</mat-icon>
|
||||
<span class="text">{{ T.MH.HM.KEYBOARD | translate }}</span>
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
(click)="startTour(TourId.Sync)"
|
||||
mat-menu-item
|
||||
>
|
||||
<mat-icon>directions</mat-icon>
|
||||
<span class="text">{{ T.MH.HM.SYNC | translate }}</span>
|
||||
</button>
|
||||
<button
|
||||
(click)="startTour(TourId.IssueProviders)"
|
||||
mat-menu-item
|
||||
>
|
||||
<mat-icon>directions</mat-icon>
|
||||
<span class="text">{{ T.MH.HM.CALENDARS | translate }}</span>
|
||||
</button>
|
||||
</ng-template>
|
||||
</mat-menu>
|
||||
|
||||
<plugin-menu></plugin-menu>
|
||||
|
||||
<button
|
||||
#menuEntry
|
||||
class="route-link tour-settingsMenuBtn"
|
||||
mat-menu-item
|
||||
routerLink="config"
|
||||
routerLinkActive="isActiveRoute"
|
||||
>
|
||||
<mat-icon>settings</mat-icon>
|
||||
<span class="text">{{ T.MH.SETTINGS | translate }}</span>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<!-- NOTE: needs to be here for mat menu styles always to be loaded -->
|
||||
<mat-menu></mat-menu>
|
||||
|
|
@ -1,177 +0,0 @@
|
|||
@use '../../../styles/_globals.scss' as *;
|
||||
|
||||
// MAIN LAYOUT
|
||||
// -----------
|
||||
:host {
|
||||
flex-grow: 1;
|
||||
max-width: var(--side-nav-width);
|
||||
width: var(--side-nav-width);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
// mobile viewport bug fix
|
||||
min-height: -webkit-fill-available;
|
||||
|
||||
&.minimal-nav {
|
||||
max-width: 42px;
|
||||
|
||||
@include mq(xxs) {
|
||||
max-width: 52px;
|
||||
}
|
||||
|
||||
.expand-btn mat-icon {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.expand-btn .title {
|
||||
display: none;
|
||||
}
|
||||
|
||||
::ng-deep {
|
||||
@include mq(xxs, max) {
|
||||
.badge {
|
||||
margin-right: -40px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.additional-btn {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.mat-mdc-menu-item {
|
||||
padding: 0 8px;
|
||||
|
||||
@include mq(xxs) {
|
||||
padding: 0 13px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a,
|
||||
button {
|
||||
height: 44px !important;
|
||||
line-height: 44px !important;
|
||||
min-height: 44px !important;
|
||||
}
|
||||
|
||||
a {
|
||||
color: rgba(var(--palette-foreground-text), var(--palette-foreground-text-alpha));
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: var(--side-nav-width-switch-l)) {
|
||||
:host {
|
||||
max-width: var(--side-nav-width-l);
|
||||
width: var(--side-nav-width-l);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
:host {
|
||||
max-width: var(--side-nav-width-mobile);
|
||||
width: var(--side-nav-width-mobile);
|
||||
}
|
||||
}
|
||||
|
||||
section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.main {
|
||||
margin-bottom: var(--s);
|
||||
padding-bottom: var(--s);
|
||||
|
||||
:host-context(.isMac.isElectron) & {
|
||||
padding-top: var(--mac-title-bar-padding);
|
||||
}
|
||||
|
||||
&:after {
|
||||
bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.app {
|
||||
margin-top: auto;
|
||||
padding-top: var(--s);
|
||||
|
||||
&:after {
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.other-task-level-section,
|
||||
.main,
|
||||
.tags,
|
||||
.app {
|
||||
position: relative;
|
||||
|
||||
&:after {
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: var(--s2);
|
||||
right: var(--s2);
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.tags,
|
||||
.projects {
|
||||
margin-bottom: var(--s);
|
||||
padding-bottom: var(--s);
|
||||
}
|
||||
|
||||
// LIST-ITEMS
|
||||
// ----------
|
||||
|
||||
.route-link.isActiveRoute {
|
||||
background-color: mat-css-color-primary(500, 0.04);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.route-link.isActiveRoute,
|
||||
.route-link.isActiveRoute mat-icon {
|
||||
color: var(--palette-primary-800);
|
||||
}
|
||||
|
||||
body.isDarkTheme .route-link.isActiveRoute,
|
||||
body.isDarkTheme .route-link.isActiveRoute mat-icon {
|
||||
color: var(--c-primary);
|
||||
}
|
||||
|
||||
// OTHER-ITEMS
|
||||
// ----------
|
||||
|
||||
.expand-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-weight: bold;
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
> mat-icon:last-of-type {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
> mat-icon:last-of-type {
|
||||
margin-right: 0;
|
||||
//margin-left: -10px;
|
||||
opacity: 0.4;
|
||||
transition: var(--transition-standard);
|
||||
}
|
||||
|
||||
&.isExpanded ::ng-deep mat-icon:last-of-type {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.no-tags-info {
|
||||
padding: 4px 16px;
|
||||
font-style: italic;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
// import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
//
|
||||
// import { SideNavComponent } from './side-nav.component';
|
||||
//
|
||||
// describe('ProjectListComponent', () => {
|
||||
// let component: SideNavComponent;
|
||||
// let fixture: ComponentFixture<SideNavComponent>;
|
||||
//
|
||||
// beforeEach(async(() => {
|
||||
// TestBed.configureTestingModule({
|
||||
// declarations: [ SideNavComponent ]
|
||||
// })
|
||||
// .compileComponents();
|
||||
// }));
|
||||
//
|
||||
// beforeEach(() => {
|
||||
// fixture = TestBed.createComponent(SideNavComponent);
|
||||
// component = fixture.componentInstance;
|
||||
// fixture.detectChanges();
|
||||
// });
|
||||
//
|
||||
// it('should create', () => {
|
||||
// expect(component).toBeTruthy();
|
||||
// });
|
||||
// });
|
||||
|
|
@ -1,321 +0,0 @@
|
|||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
ElementRef,
|
||||
HostBinding,
|
||||
HostListener,
|
||||
inject,
|
||||
OnDestroy,
|
||||
signal,
|
||||
viewChild,
|
||||
viewChildren,
|
||||
} from '@angular/core';
|
||||
import { ProjectService } from '../../features/project/project.service';
|
||||
import { T } from '../../t.const';
|
||||
import { DialogCreateProjectComponent } from '../../features/project/dialogs/create-project/dialog-create-project.component';
|
||||
import { Project } from '../../features/project/project.model';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { DRAG_DELAY_FOR_TOUCH_LONGER } from '../../app.constants';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { WorkContextService } from '../../features/work-context/work-context.service';
|
||||
import { standardListAnimation } from '../../ui/animations/standard-list.ani';
|
||||
import { first } from 'rxjs/operators';
|
||||
import { TagService } from '../../features/tag/tag.service';
|
||||
import { Tag } from '../../features/tag/tag.model';
|
||||
import { WorkContextType } from '../../features/work-context/work-context.model';
|
||||
import { expandFadeAnimation } from '../../ui/animations/expand.ani';
|
||||
import { FocusKeyManager } from '@angular/cdk/a11y';
|
||||
import {
|
||||
MatMenu,
|
||||
MatMenuContent,
|
||||
MatMenuItem,
|
||||
MatMenuTrigger,
|
||||
} from '@angular/material/menu';
|
||||
import { LayoutService } from '../layout/layout.service';
|
||||
import { TaskService } from '../../features/tasks/task.service';
|
||||
import { LS } from '../../core/persistence/storage-keys.const';
|
||||
import { TODAY_TAG } from '../../features/tag/tag.const';
|
||||
import { TourId } from '../../features/shepherd/shepherd-steps.const';
|
||||
import { ShepherdService } from '../../features/shepherd/shepherd.service';
|
||||
import { getGithubErrorUrl } from '../../core/error-handler/global-error-handler.util';
|
||||
import { IS_MOUSE_PRIMARY, IS_TOUCH_PRIMARY } from '../../util/is-mouse-primary';
|
||||
import { GlobalConfigService } from '../../features/config/global-config.service';
|
||||
import { CdkDrag, CdkDragDrop, CdkDropList } from '@angular/cdk/drag-drop';
|
||||
import { moveItemBeforeItem } from '../../util/move-item-before-item';
|
||||
import { Store } from '@ngrx/store';
|
||||
import {
|
||||
selectAllProjectsExceptInbox,
|
||||
selectUnarchivedHiddenProjectIds,
|
||||
selectUnarchivedVisibleProjects,
|
||||
} from '../../features/project/store/project.selectors';
|
||||
import { SideNavItemComponent } from './side-nav-item/side-nav-item.component';
|
||||
import { RouterLink, RouterLinkActive } from '@angular/router';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { ContextMenuComponent } from '../../ui/context-menu/context-menu.component';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { AsyncPipe } from '@angular/common';
|
||||
import { toggleHideFromMenu } from '../../features/project/store/project.actions';
|
||||
import { PluginMenuComponent } from '../../plugins/ui/plugin-menu.component';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
|
||||
@Component({
|
||||
selector: 'side-nav',
|
||||
templateUrl: './side-nav.component.html',
|
||||
styleUrls: ['./side-nav.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
animations: [standardListAnimation, expandFadeAnimation],
|
||||
imports: [
|
||||
SideNavItemComponent,
|
||||
MatMenuItem,
|
||||
RouterLink,
|
||||
RouterLinkActive,
|
||||
MatIcon,
|
||||
MatIconButton,
|
||||
MatTooltip,
|
||||
ContextMenuComponent,
|
||||
CdkDropList,
|
||||
CdkDrag,
|
||||
MatMenuTrigger,
|
||||
MatMenu,
|
||||
MatMenuContent,
|
||||
TranslatePipe,
|
||||
AsyncPipe,
|
||||
PluginMenuComponent,
|
||||
],
|
||||
})
|
||||
export class SideNavComponent implements OnDestroy {
|
||||
readonly tagService = inject(TagService);
|
||||
readonly projectService = inject(ProjectService);
|
||||
readonly workContextService = inject(WorkContextService);
|
||||
private readonly _matDialog = inject(MatDialog);
|
||||
private readonly _layoutService = inject(LayoutService);
|
||||
private readonly _taskService = inject(TaskService);
|
||||
private readonly _shepherdService = inject(ShepherdService);
|
||||
private readonly _globalConfigService = inject(GlobalConfigService);
|
||||
private readonly _store = inject(Store);
|
||||
|
||||
navEntries = viewChildren<MatMenuItem>('menuEntry');
|
||||
IS_MOUSE_PRIMARY = IS_MOUSE_PRIMARY;
|
||||
IS_TOUCH_PRIMARY = IS_TOUCH_PRIMARY;
|
||||
DRAG_DELAY_FOR_TOUCH_LONGER = DRAG_DELAY_FOR_TOUCH_LONGER;
|
||||
|
||||
keyboardFocusTimeout?: number;
|
||||
projectExpandBtn = viewChild('projectExpandBtn', { read: ElementRef });
|
||||
isProjectsExpanded = signal(this.fetchProjectListState());
|
||||
|
||||
allNonInboxProjects = toSignal(this._store.select(selectAllProjectsExceptInbox), {
|
||||
initialValue: [],
|
||||
});
|
||||
private _allVisibleProjects = toSignal(
|
||||
this._store.select(selectUnarchivedVisibleProjects),
|
||||
{ initialValue: [] },
|
||||
);
|
||||
private _activeWorkContextId = toSignal(this.workContextService.activeWorkContextId$, {
|
||||
initialValue: null,
|
||||
});
|
||||
|
||||
nonHiddenProjects = computed(() => {
|
||||
const isExpanded = this.isProjectsExpanded();
|
||||
const projects = this._allVisibleProjects();
|
||||
if (isExpanded) {
|
||||
return projects;
|
||||
}
|
||||
const activeId = this._activeWorkContextId();
|
||||
return projects.filter((p) => p.id === activeId);
|
||||
});
|
||||
|
||||
tagExpandBtn = viewChild('tagExpandBtn', { read: ElementRef });
|
||||
isTagsExpanded = signal(this.fetchTagListState());
|
||||
|
||||
private _tagListToDisplay = toSignal(this.tagService.tagsNoMyDayAndNoList$, {
|
||||
initialValue: [],
|
||||
});
|
||||
|
||||
tagList = computed(() => {
|
||||
const isExpanded = this.isTagsExpanded();
|
||||
const tags = this._tagListToDisplay();
|
||||
if (isExpanded) {
|
||||
return tags;
|
||||
}
|
||||
const activeId = this._activeWorkContextId();
|
||||
return tags.filter((t) => t.id === activeId);
|
||||
});
|
||||
T: typeof T = T;
|
||||
activeWorkContextId?: string | null;
|
||||
WorkContextType: typeof WorkContextType = WorkContextType;
|
||||
TourId: typeof TourId = TourId;
|
||||
private keyManager?: FocusKeyManager<MatMenuItem>;
|
||||
private _subs: Subscription = new Subscription();
|
||||
private _cachedIssueUrl?: string;
|
||||
|
||||
@HostBinding('class') get cssClass(): string {
|
||||
return this._globalConfigService.cfg()?.misc.isUseMinimalNav ? 'minimal-nav' : '';
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this._subs.add(
|
||||
this.workContextService.activeWorkContextId$.subscribe(
|
||||
(id) => (this.activeWorkContextId = id),
|
||||
),
|
||||
);
|
||||
|
||||
// Effect to handle side nav visibility changes
|
||||
effect(() => {
|
||||
const isShow = this._layoutService.isShowSideNav();
|
||||
const navEntries = this.navEntries();
|
||||
if (navEntries && isShow) {
|
||||
this.keyManager = new FocusKeyManager<MatMenuItem>(
|
||||
navEntries,
|
||||
).withVerticalOrientation(true);
|
||||
window.clearTimeout(this.keyboardFocusTimeout);
|
||||
this.keyboardFocusTimeout = window.setTimeout(() => {
|
||||
this.keyManager?.setFirstItemActive();
|
||||
}, 100);
|
||||
// this.keyManager.change.subscribe((v) => Log.log('this.keyManager.change', v));
|
||||
} else if (!isShow) {
|
||||
this._taskService.focusFirstTaskIfVisible();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@HostListener('keydown', ['$event'])
|
||||
onKeydown(event: KeyboardEvent): void {
|
||||
this.keyManager?.onKeydown(event);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this._subs.unsubscribe();
|
||||
window.clearTimeout(this.keyboardFocusTimeout);
|
||||
}
|
||||
|
||||
addProject(): void {
|
||||
this._matDialog.open(DialogCreateProjectComponent, {
|
||||
restoreFocus: true,
|
||||
});
|
||||
}
|
||||
|
||||
fetchProjectListState(): boolean {
|
||||
return localStorage.getItem(LS.IS_PROJECT_LIST_EXPANDED) === 'true';
|
||||
}
|
||||
|
||||
private _storeProjectListState(isExpanded: boolean): void {
|
||||
this.isProjectsExpanded.set(isExpanded);
|
||||
localStorage.setItem(LS.IS_PROJECT_LIST_EXPANDED, isExpanded.toString());
|
||||
}
|
||||
|
||||
fetchTagListState(): boolean {
|
||||
return localStorage.getItem(LS.IS_TAG_LIST_EXPANDED) === 'true';
|
||||
}
|
||||
|
||||
storeTagListState(isExpanded: boolean): void {
|
||||
this.isTagsExpanded.set(isExpanded);
|
||||
localStorage.setItem(LS.IS_TAG_LIST_EXPANDED, isExpanded.toString());
|
||||
}
|
||||
|
||||
toggleExpandProjects(): void {
|
||||
const newState: boolean = !this.isProjectsExpanded();
|
||||
this._storeProjectListState(newState);
|
||||
}
|
||||
|
||||
toggleExpandProjectsLeftRight(ev: KeyboardEvent): void {
|
||||
if (ev.key === 'ArrowLeft' && this.isProjectsExpanded()) {
|
||||
this._storeProjectListState(false);
|
||||
} else if (ev.key === 'ArrowRight' && !this.isProjectsExpanded()) {
|
||||
this._storeProjectListState(true);
|
||||
}
|
||||
}
|
||||
|
||||
checkFocusProject(ev: KeyboardEvent): void {
|
||||
if (ev.key === 'ArrowLeft' && this.projectExpandBtn()?.nativeElement) {
|
||||
const targetIndex = this.navEntries().findIndex((value) => {
|
||||
return (
|
||||
typeof value._getHostElement === 'function' &&
|
||||
value._getHostElement() === this.projectExpandBtn()?.nativeElement
|
||||
);
|
||||
});
|
||||
|
||||
if (targetIndex) {
|
||||
this.keyManager?.setActiveItem(targetIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toggleExpandTags(): void {
|
||||
const newState: boolean = !this.isTagsExpanded();
|
||||
this.storeTagListState(newState);
|
||||
}
|
||||
|
||||
toggleExpandTagsLeftRight(ev: KeyboardEvent): void {
|
||||
if (ev.key === 'ArrowLeft' && this.isTagsExpanded()) {
|
||||
this.storeTagListState(false);
|
||||
} else if (ev.key === 'ArrowRight' && !this.isTagsExpanded()) {
|
||||
this.storeTagListState(true);
|
||||
}
|
||||
}
|
||||
|
||||
checkFocusTag(ev: KeyboardEvent): void {
|
||||
if (ev.key === 'ArrowLeft' && this.tagExpandBtn()?.nativeElement) {
|
||||
const targetIndex = this.navEntries().findIndex((value) => {
|
||||
return (
|
||||
typeof value._getHostElement === 'function' &&
|
||||
value._getHostElement() === this.tagExpandBtn()?.nativeElement
|
||||
);
|
||||
});
|
||||
|
||||
if (targetIndex) {
|
||||
this.keyManager?.setActiveItem(targetIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
startTour(id: TourId): void {
|
||||
this._shepherdService.show(id);
|
||||
}
|
||||
|
||||
getGithubErrorUrl(): string {
|
||||
if (!this._cachedIssueUrl) {
|
||||
this._cachedIssueUrl = getGithubErrorUrl('', undefined, true);
|
||||
}
|
||||
return this._cachedIssueUrl;
|
||||
}
|
||||
|
||||
toggleProjectVisibility(project: Project): void {
|
||||
this._store.dispatch(toggleHideFromMenu({ id: project.id }));
|
||||
}
|
||||
|
||||
async dropOnProjectList(
|
||||
allItems: Project[],
|
||||
ev: CdkDragDrop<string, string, Project>,
|
||||
): Promise<void> {
|
||||
if (ev.previousContainer === ev.container && ev.currentIndex !== ev.previousIndex) {
|
||||
const tag = ev.item.data;
|
||||
const allIds = allItems.map((p) => p.id);
|
||||
const targetTagId = allIds[ev.currentIndex] as string;
|
||||
if (targetTagId) {
|
||||
const hiddenIds = await this._store
|
||||
.select(selectUnarchivedHiddenProjectIds)
|
||||
.pipe(first())
|
||||
.toPromise();
|
||||
const newIds = [...moveItemBeforeItem(allIds, tag.id, targetTagId), ...hiddenIds];
|
||||
this.projectService.updateOrder(newIds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dropOnTagList(allItems: Tag[], ev: CdkDragDrop<string, string, Tag>): void {
|
||||
if (ev.previousContainer === ev.container && ev.currentIndex !== ev.previousIndex) {
|
||||
const tag = ev.item.data;
|
||||
const allIds = allItems.map((p) => p.id);
|
||||
const targetTagId = allIds[ev.currentIndex] as string;
|
||||
if (targetTagId) {
|
||||
// special today list should always be first
|
||||
const newIds = [TODAY_TAG.id, ...moveItemBeforeItem(allIds, tag.id, targetTagId)];
|
||||
this.tagService.updateOrder(newIds);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -48,6 +48,10 @@ export enum LS {
|
|||
DONE_TASKS_HIDDEN = 'DONE_TASKS_HIDDEN',
|
||||
LATER_TODAY_TASKS_HIDDEN = 'LATER_TODAY_TASKS_HIDDEN',
|
||||
OVERDUE_TASKS_HIDDEN = 'OVERDUE_TASKS_HIDDEN',
|
||||
|
||||
// Magic side nav
|
||||
NAV_SIDEBAR_EXPANDED = 'SUP_NAV_SIDEBAR_EXPANDED',
|
||||
NAV_SIDEBAR_WIDTH = 'SUP_NAV_SIDEBAR_WIDTH',
|
||||
}
|
||||
|
||||
// SESSION STORAGE
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ export const DEFAULT_GLOBAL_CONFIG: GlobalConfigState = {
|
|||
openProjectNotes: 'Shift+N',
|
||||
toggleTaskViewCustomizerPanel: 'c',
|
||||
toggleIssuePanel: 'p',
|
||||
toggleSideNav: 'Shift+D',
|
||||
focusSideNav: 'Shift+D',
|
||||
showHelp: '?',
|
||||
showSearchBar: 'Shift+F',
|
||||
toggleBacklog: 'b',
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ export const KEYBOARD_SETTINGS_FORM_CFG: ConfigFormSection<KeyboardConfig> = {
|
|||
},
|
||||
},
|
||||
{
|
||||
key: 'toggleSideNav',
|
||||
key: 'focusSideNav',
|
||||
type: 'keyboard',
|
||||
templateOptions: {
|
||||
label: T.GCF.KEYBOARD.TOGGLE_SIDE_NAV,
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export type KeyboardConfig = Readonly<{
|
|||
showHelp?: string | null;
|
||||
showSearchBar?: string | null;
|
||||
addNewNote?: string | null;
|
||||
toggleSideNav?: string | null;
|
||||
focusSideNav?: string | null;
|
||||
openProjectNotes?: string | null;
|
||||
toggleTaskViewCustomizerPanel?: string | null;
|
||||
toggleIssuePanel?: string | null;
|
||||
|
|
|
|||
|
|
@ -436,16 +436,26 @@ export const SHEPHERD_STEPS = (
|
|||
text: 'Open the menu (<span class="material-icons">menu</span>)',
|
||||
beforeShowPromise: () => {
|
||||
// If nav is always visible, skip this step
|
||||
if (layoutService.isNavAlwaysVisible()) {
|
||||
if (layoutService.isMobileNav()) {
|
||||
setTimeout(() => shepherdService.next(), 0);
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
when: nextOnObs(
|
||||
layoutService.isShowSideNav$.pipe(filter((v) => !!v)),
|
||||
shepherdService,
|
||||
),
|
||||
when: {
|
||||
show: () => {
|
||||
// If nav is always visible, skip immediately
|
||||
if (layoutService.isMobileNav()) {
|
||||
setTimeout(() => shepherdService.next(), 0);
|
||||
} else {
|
||||
// For mobile, wait for the burger button to be clicked
|
||||
// We'll use a simple timeout or manual next for now
|
||||
// since the mobile nav opening doesn't have an observable
|
||||
// TODO better implementation
|
||||
setTimeout(() => shepherdService.next(), 8000);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// ------------------------------
|
||||
|
|
@ -491,16 +501,24 @@ export const SHEPHERD_STEPS = (
|
|||
beforeShowPromise: () => {
|
||||
return router.navigate(['']).then(() => {
|
||||
// If nav is always visible, skip this step
|
||||
if (layoutService.isNavAlwaysVisible()) {
|
||||
if (layoutService.isMobileNav()) {
|
||||
setTimeout(() => shepherdService.next(), 0);
|
||||
}
|
||||
});
|
||||
},
|
||||
text: 'Open the menu (<span class="material-icons">menu</span>)',
|
||||
when: nextOnObs(
|
||||
layoutService.isShowSideNav$.pipe(filter((v) => !!v)),
|
||||
shepherdService,
|
||||
),
|
||||
when: {
|
||||
show: () => {
|
||||
// If nav is always visible, skip immediately
|
||||
if (layoutService.isMobileNav()) {
|
||||
setTimeout(() => shepherdService.next(), 0);
|
||||
} else {
|
||||
// For mobile, wait for the burger button to be clicked
|
||||
// TODO better implementation
|
||||
setTimeout(() => shepherdService.next(), 8000);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Configure Sync',
|
||||
|
|
|
|||
|
|
@ -20,11 +20,9 @@ import {
|
|||
hideAddTaskBar,
|
||||
hideNonTaskSidePanelContent,
|
||||
hidePluginPanel,
|
||||
hideSideNav,
|
||||
showAddTaskBar,
|
||||
toggleIssuePanel,
|
||||
toggleShowNotes,
|
||||
toggleSideNav,
|
||||
} from '../core-ui/layout/store/layout.actions';
|
||||
|
||||
/**
|
||||
|
|
@ -36,8 +34,6 @@ export const ALLOWED_PLUGIN_ACTIONS = [
|
|||
// Layout
|
||||
showAddTaskBar,
|
||||
hideAddTaskBar,
|
||||
hideSideNav,
|
||||
toggleSideNav,
|
||||
toggleShowNotes,
|
||||
hideNonTaskSidePanelContent,
|
||||
toggleIssuePanel,
|
||||
|
|
|
|||
108
src/app/util/ls-util.ts
Normal file
108
src/app/util/ls-util.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
/**
|
||||
* localStorage utility functions for Super Productivity
|
||||
*/
|
||||
|
||||
/**
|
||||
* Check if a key exists in localStorage
|
||||
*/
|
||||
export const lsHasKey = (key: string): boolean => {
|
||||
return localStorage.getItem(key) !== null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear localStorage completely
|
||||
*/
|
||||
export const clearLS = (): void => {
|
||||
localStorage.clear();
|
||||
};
|
||||
|
||||
/**
|
||||
* Get item from localStorage with default value
|
||||
*/
|
||||
export const lsGetItem = <T = string>(
|
||||
key: string,
|
||||
defaultValue?: T,
|
||||
): T | string | null => {
|
||||
const item = localStorage.getItem(key);
|
||||
return item ?? defaultValue ?? null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get item from localStorage and parse as number, with optional default
|
||||
*/
|
||||
export const lsGetNumber = (key: string, defaultValue = 0): number => {
|
||||
const item = localStorage.getItem(key);
|
||||
return item ? +item : defaultValue;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get item from localStorage and parse as boolean, with optional default
|
||||
*/
|
||||
export const lsGetBoolean = (key: string, defaultValue = false): boolean => {
|
||||
const item = localStorage.getItem(key);
|
||||
if (item === null) return defaultValue;
|
||||
return item === 'true';
|
||||
};
|
||||
|
||||
/**
|
||||
* Get item from localStorage and parse as JSON, with optional default
|
||||
*/
|
||||
export const lsGetJSON = <T>(key: string, defaultValue?: T): T | null => {
|
||||
try {
|
||||
const item = localStorage.getItem(key);
|
||||
return item ? JSON.parse(item) : (defaultValue ?? null);
|
||||
} catch {
|
||||
return defaultValue ?? null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Set item in localStorage
|
||||
*/
|
||||
export const lsSetItem = (key: string, value: string | number | boolean): void => {
|
||||
localStorage.setItem(key, String(value));
|
||||
};
|
||||
|
||||
/**
|
||||
* Set item in localStorage as JSON
|
||||
*/
|
||||
export const lsSetJSON = (key: string, value: unknown): void => {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove item from localStorage
|
||||
*/
|
||||
export const lsRemoveItem = (key: string): void => {
|
||||
localStorage.removeItem(key);
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove multiple items from localStorage
|
||||
*/
|
||||
export const lsRemoveItems = (keys: string[]): void => {
|
||||
keys.forEach((key) => localStorage.removeItem(key));
|
||||
};
|
||||
|
||||
/**
|
||||
* Get boolean value from localStorage with fallback
|
||||
*/
|
||||
export const readBoolLS = (key: string, fallback: boolean): boolean => {
|
||||
const v = localStorage.getItem(key);
|
||||
return v === null ? fallback : v === 'true';
|
||||
};
|
||||
|
||||
/**
|
||||
* Get number from localStorage with bounds checking
|
||||
*/
|
||||
export const readNumberLSBounded = (
|
||||
key: string,
|
||||
min: number,
|
||||
max: number,
|
||||
): number | null => {
|
||||
const v = localStorage.getItem(key);
|
||||
if (v == null) return null;
|
||||
const n = parseInt(v, 10);
|
||||
if (Number.isNaN(n)) return null;
|
||||
return Math.max(min, Math.min(max, n));
|
||||
};
|
||||
|
|
@ -1681,7 +1681,7 @@
|
|||
"TOGGLE_BOOKMARKS": "Show/Hide Bookmark Bar",
|
||||
"TOGGLE_ISSUE_PANEL": "Show/Hide Issue Panel",
|
||||
"TOGGLE_PLAY": "Start/Stop Task",
|
||||
"TOGGLE_SIDE_NAV": "Show & Focus/Hide Sidenav",
|
||||
"TOGGLE_SIDE_NAV": "Focus Sidenav",
|
||||
"TOGGLE_TASK_VIEW_CUSTOMIZER_PANEL": "Toggle Filter/Group/Sort Panel",
|
||||
"TRIGGER_SYNC": "Trigger sync (if configured)",
|
||||
"ZOOM_DEFAULT": "Zoom default (Desktop only)",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
height="24px"
|
||||
viewBox="0 -960 960 960"
|
||||
width="24px"
|
||||
fill="#">
|
||||
<path d="M680-120q-50 0-85-35t-35-85q0-50 35-85t85-35q50 0 85 35t35 85q0 50-35 85t-85 35Zm0-280q-13 0-21.5-8.5T650-430v-20q0-13 8.5-21.5T680-480q13 0 21.5 8.5T710-450v20q0 13-8.5 21.5T680-400Zm0 320q13 0 21.5 8.5T710-50v20q0 13-8.5 21.5T680 0q-13 0-21.5-8.5T650-30v-20q0-13 8.5-21.5T680-80Zm114-274q-9-9-9-21.5t9-21.5l14-14q9-9 21-8.5t21 9.5q9 9 9 21t-9 21l-14 14q-9 9-21 9t-21-9ZM567-127q9 9 9 21t-9 21l-15 15q-9 9-21 9t-21-9q-9-9-9-21t9-21l15-15q9-9 21-9t21 9Zm273-113q0-13 8.5-21.5T870-270h20q13 0 21.5 8.5T920-240q0 13-8.5 21.5T890-210h-20q-13 0-21.5-8.5T840-240Zm-320 0q0 13-8.5 21.5T490-210h-20q-13 0-21.5-8.5T440-240q0-13 8.5-21.5T470-270h20q13 0 21.5 8.5T520-240Zm274 113q9-9 21-8.5t21 8.5l15 14q9 9 9 21t-9 21q-9 9-21.5 9T808-71l-14-14q-8-9-8.5-21t8.5-21ZM567-354q-9 9-21.5 9t-21.5-9l-14-14q-8-9-8.5-21t8.5-21q9-9 21-8.5t21 8.5l15 14q9 9 9 21t-9 21ZM200-80q-33 0-56.5-23.5T120-160v-560q0-33 23.5-56.5T200-800h40v-40q0-17 11.5-28.5T280-880q17 0 28.5 11.5T320-840v40h320v-40q0-17 11.5-28.5T680-880q17 0 28.5 11.5T720-840v40h40q33 0 56.5 23.5T840-720v120q0 17-11.5 28.5T800-560H200v400h120q17 0 28.5 11.5T360-120q0 17-11.5 28.5T320-80H200Zm0-560h560v-80H200v80Zm0 0v-80 80Z" />
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24" width="24" viewBox="0 -960 960 960">
|
||||
<g transform="translate(480,-480) scale(0.80) translate(-480,480)">
|
||||
<path fill="currentColor" d="M680-120q-50 0-85-35t-35-85q0-50 35-85t85-35q50 0 85 35t35 85q0 50-35 85t-85 35Zm0-280q-13 0-21.5-8.5T650-430v-20q0-13 8.5-21.5T680-480q13 0 21.5 8.5T710-450v20q0 13-8.5 21.5T680-400Zm0 320q13 0 21.5 8.5T710-50v20q0 13-8.5 21.5T680 0q-13 0-21.5-8.5T650-30v-20q0-13 8.5-21.5T680-80Zm114-274q-9-9-9-21.5t9-21.5l14-14q9-9 21-8.5t21 9.5q9 9 9 21t-9 21l-14 14q-9 9-21 9t-21-9ZM567-127q9 9 9 21t-9 21l-15 15q-9 9-21 9t-21-9q-9-9-9-21t9-21l15-15q9-9 21-9t21 9Zm273-113q0-13 8.5-21.5T870-270h20q13 0 21.5 8.5T920-240q0 13-8.5 21.5T890-210h-20q-13 0-21.5-8.5T840-240Zm-320 0q0 13-8.5 21.5T490-210h-20q-13 0-21.5-8.5T440-240q0-13 8.5-21.5T470-270h20q13 0 21.5 8.5T520-240Zm274 113q9-9 21-8.5t21 8.5l15 14q9 9 9 21t-9 21q-9 9-21.5 9T808-71l-14-14q-8-9-8.5-21t8.5-21ZM567-354q-9 9-21.5 9t-21.5-9l-14-14q-8-9-8.5-21t8.5-21q9-9 21-8.5t21 8.5l15 14q9 9 9 21t-9 21ZM200-80q-33 0-56.5-23.5T120-160v-560q0-33 23.5-56.5T200-800h40v-40q0-17 11.5-28.5T280-880q17 0 28.5 11.5T320-840v40h320v-40q0-17 11.5-28.5T680-880q17 0 28.5 11.5T720-840v40h40q33 0 56.5 23.5T840-720v120q0 17-11.5 28.5T800-560H200v400h120q17 0 28.5 11.5T360-120q0 17-11.5 28.5T320-80H200Zm0-560h560v-80H200v80Zm0 0v-80 80Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
|
@ -40,7 +40,7 @@ body.isDarkTheme {
|
|||
|
||||
/* Component backgrounds */
|
||||
--card-bg: var(--arc-bg-medium);
|
||||
--sidebar-bg: var(--arc-sidebar);
|
||||
--sidenav-bg: var(--arc-sidebar);
|
||||
--selected-task-bg-color: var(--arc-bg-medium);
|
||||
--banner-bg: var(--arc-bg-medium);
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ body.isDarkTheme {
|
|||
|
||||
/* Component backgrounds */
|
||||
--card-bg: #141414;
|
||||
--sidebar-bg: #0f0f0f;
|
||||
--sidenav-bg: #0f0f0f;
|
||||
--selected-task-bg-color: #1f1f1f;
|
||||
--banner-bg: #1a1a1a;
|
||||
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ body.isDarkTheme {
|
|||
|
||||
/* Component backgrounds */
|
||||
--card-bg: #2c2e3a;
|
||||
--sidebar-bg: #21222c;
|
||||
--sidenav-bg: #21222c;
|
||||
--selected-task-bg-color: #343746;
|
||||
--banner-bg: #343746;
|
||||
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ body:not(.isDarkTheme) {
|
|||
|
||||
/* Component backgrounds */
|
||||
--card-bg: #fffbf0;
|
||||
--sidebar-bg: var(--everforest-bg1);
|
||||
--sidenav-bg: var(--everforest-bg1);
|
||||
--selected-task-bg-color: var(--everforest-bg2);
|
||||
--banner-bg: var(--everforest-bg2);
|
||||
|
||||
|
|
@ -272,7 +272,7 @@ body.isDarkTheme {
|
|||
|
||||
/* Component backgrounds */
|
||||
--card-bg: var(--everforest-bg1);
|
||||
--sidebar-bg: var(--everforest-bg-dim);
|
||||
--sidenav-bg: var(--everforest-bg-dim);
|
||||
--selected-task-bg-color: var(--everforest-bg2);
|
||||
--banner-bg: var(--everforest-bg2);
|
||||
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ body.isDarkTheme {
|
|||
|
||||
/* Glass component backgrounds - darker */
|
||||
--card-bg: rgba(255, 255, 255, 0.05);
|
||||
--sidebar-bg: rgba(255, 255, 255, 0.02);
|
||||
--sidenav-bg: rgba(255, 255, 255, 0.02);
|
||||
--selected-task-bg-color: rgba(255, 255, 255, 0.08);
|
||||
--banner-bg: rgba(255, 255, 255, 0.06);
|
||||
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ body.isDarkTheme {
|
|||
|
||||
/* Component backgrounds */
|
||||
--card-bg: var(--nord2);
|
||||
--sidebar-bg: var(--nord0);
|
||||
--sidenav-bg: var(--nord0);
|
||||
--selected-task-bg-color: var(--nord3);
|
||||
--banner-bg: var(--nord3);
|
||||
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ body:not(.isDarkTheme) {
|
|||
|
||||
/* Component backgrounds */
|
||||
--card-bg: #ffffff;
|
||||
--sidebar-bg: var(--nord5);
|
||||
--sidenav-bg: var(--nord5);
|
||||
--selected-task-bg-color: #f2f4f8;
|
||||
--banner-bg: #f2f4f8;
|
||||
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ body.isDarkTheme {
|
|||
|
||||
/* Component backgrounds */
|
||||
--card-bg: #2d2d2d;
|
||||
--sidebar-bg: #0f0f0f;
|
||||
--sidenav-bg: #0f0f0f;
|
||||
}
|
||||
|
||||
body.isDarkTheme mat-sidenav-content {
|
||||
|
|
|
|||
|
|
@ -332,8 +332,15 @@ body {
|
|||
--card-bg: #ffffff;
|
||||
|
||||
// Navigation & Panel backgrounds
|
||||
--sidebar-bg: var(--bg);
|
||||
//--sidebar-border-color: var(--extra-border-color);
|
||||
--sidenav-bg: transparent;
|
||||
--sidenav-bg-mobile: var(--bg);
|
||||
--sidenav-active: rgba(255, 255, 255, 1.25);
|
||||
--sidenav-active-text: var(--palette-primary-800);
|
||||
--sidebar-border: var(--divider-color);
|
||||
--sidebar-hover: var(--palette-background-hover);
|
||||
--sidebar-text: var(--text-color);
|
||||
--sidebar-text-secondary: var(--text-color-muted);
|
||||
--overlay-bg: var(--c-backdrop, rgba(0, 0, 0, 0.6));
|
||||
--right-panel-bg: #fff;
|
||||
|
||||
// Text colors
|
||||
|
|
@ -375,9 +382,9 @@ body {
|
|||
--task-selected-shadow: var(--whiteframe-shadow-3dp);
|
||||
|
||||
// Scrollbar colors
|
||||
--scrollbar-thumb: #888;
|
||||
--scrollbar-thumb-hover: #555;
|
||||
--scrollbar-track: #f1f1f1;
|
||||
--scrollbar-thumb: rgba(136, 136, 136, 0.5);
|
||||
--scrollbar-thumb-hover: rgba(85, 85, 85, 0.7);
|
||||
--scrollbar-track: rgba(241, 241, 241, 0.3);
|
||||
|
||||
// Chip colors
|
||||
--chip-outline-color: rgba(125, 125, 125, 0.4);
|
||||
|
|
@ -448,14 +455,21 @@ body.isDarkTheme {
|
|||
--card-bg: var(--dark2);
|
||||
|
||||
// Navigation & Panel backgrounds
|
||||
--sidebar-bg: var(--dark1);
|
||||
--sidebar-border-color: var(--dark12);
|
||||
--sidenav-bg: transparent;
|
||||
--sidenav-bg-mobile: var(--bg);
|
||||
--sidenav-active: rgba(255, 255, 255, 0.075);
|
||||
--sidenav-active-text: var(--c-primary);
|
||||
--sidebar-border: var(--divider-color);
|
||||
--sidebar-hover: var(--palette-background-hover);
|
||||
--sidebar-text: var(--text-color);
|
||||
--sidebar-text-secondary: var(--text-color-muted);
|
||||
--overlay-bg: var(--c-backdrop, rgba(0, 0, 0, 0.8));
|
||||
--right-panel-bg: var(--dark5);
|
||||
|
||||
// Text colors
|
||||
--text-color: rgb(230, 230, 230);
|
||||
--text-color-less-intense: rgba(235, 235, 235, 0.9);
|
||||
--text-color-muted: rgba(235, 235, 235, 0.6);
|
||||
--text-color-muted: rgba(235, 235, 235, 0.75);
|
||||
--text-color-more-intense: rgb(245, 245, 245);
|
||||
--text-color-most-intense: rgb(255, 255, 255);
|
||||
|
||||
|
|
@ -493,9 +507,9 @@ body.isDarkTheme {
|
|||
--task-selected-shadow: var(--whiteframe-shadow-4dp);
|
||||
|
||||
// Scrollbar colors
|
||||
--scrollbar-thumb: #333;
|
||||
--scrollbar-thumb-hover: #444;
|
||||
--scrollbar-track: #222;
|
||||
--scrollbar-thumb: rgba(51, 51, 51, 0.6);
|
||||
--scrollbar-thumb-hover: rgba(68, 68, 68, 0.8);
|
||||
--scrollbar-track: rgba(34, 34, 34, 0.4);
|
||||
|
||||
// Grid colors
|
||||
--grid-color: var(--c-light-10);
|
||||
|
|
|
|||
|
|
@ -124,3 +124,9 @@ body.isDarkTheme .show-light-only {
|
|||
.block {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.milk-glass {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%); // Safari support
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue