import {Component, OnInit, ViewChild, HostListener} from '@angular/core'; import { AlertController, ModalController, Platform, PopoverController, LoadingController, ToastController, NavController } from '@ionic/angular'; import { SplashScreen } from '@ionic-native/splash-screen/ngx'; import { StatusBar } from '@ionic-native/status-bar/ngx'; import { ApiService } from './service/api.service'; import { Router } from '@angular/router'; import {TREE_ACTIONS, TreeComponent} from '@circlon/angular-tree-component'; import {BehaviorSubject, fromEvent, Observable} from 'rxjs'; import {OptionPickerComponent} from './components/option-picker/option-picker.component'; import {OptionMenuComponent} from './components/option-menu/option-menu.component'; import {SelectorComponent} from './components/sharing/selector/selector.component'; import {SessionService} from './service/session.service'; import {SearchComponent} from './components/search/Search.component'; import {NodeTypeIcons} from './structures/node-types'; import {NavigationService} from './service/navigation.service'; import {DatabaseService} from './service/db/database.service'; import {EditorService} from './service/editor.service'; import {debug} from './utility'; import {AuthService} from './service/auth.service'; import {OpenerService} from './service/opener.service'; @Component({ selector: 'app-root', templateUrl: 'app.component.html', styleUrls: ['app.component.scss'] }) export class AppComponent implements OnInit { @ViewChild('menuTree') menuTree: TreeComponent; public readonly ready$: BehaviorSubject = new BehaviorSubject(false); public addChildTarget: any = false; public deleteTarget: any = false; public menuTarget: any = false; public refreshingMenu = false; public lastClickEvent: Array = []; public nodes = []; public currentPageId: string; public virtualRootPageId?: string; public options = { isExpandedField: 'expanded', animateExpand: false, scrollOnActivate: false, allowDrag: true, allowDrop: (element, { parent, index }) => { return ( !this.api.isOffline && (element.data.type === 'page' || element.data.type === 'form') && (parent.data.type === 'page' || parent.data.userRootPage) && !element.data.userRootPage && element.data.id !== parent.data.id ); }, actionMapping: { mouse: { dblClick: (tree, node, $event) => { this.navigateEditorToNode(node); }, click: (tree, node, $event) => { TREE_ACTIONS.FOCUS(tree, node, $event); TREE_ACTIONS.EXPAND(tree, node, $event); this.addChildTarget = false; this.deleteTarget = false; this.menuTarget = false; if ( !node.data.noChildren && (!node.data.level || node.data.level === 'manage') ) { this.addChildTarget = node; } if ( !node.data.noDelete && (!node.data.level || node.data.level === 'manage') ) { this.deleteTarget = node; } this.menuTarget = node; this.lastClickEvent = [tree, node, $event]; }, contextMenu: (tree, node, event) => { event.preventDefault(); event.stopPropagation(); TREE_ACTIONS.FOCUS(tree, node, event); TREE_ACTIONS.EXPAND(tree, node, event); this.addChildTarget = false; this.deleteTarget = false; this.menuTarget = false; if ( !node.data.noChildren && (!node.data.level || node.data.level === 'manage') ) { this.addChildTarget = node; } if ( !node.data.noDelete && (!node.data.level || node.data.level === 'manage') ) { this.deleteTarget = node; } this.menuTarget = node; this.lastClickEvent = [tree, node, event]; this.onNodeMenuClick(event); }, } } }; public typeIcons = NodeTypeIcons; public get appName(): string { return this.session.appName || 'Noded'; } public darkMode = false; protected loader?: any; protected hasSearchOpen = false; protected versionInterval?: any; protected showedNewVersionAlert = false; protected showedOfflineAlert = false; protected backbuttonSubscription: any; constructor( private platform: Platform, private splashScreen: SplashScreen, private statusBar: StatusBar, public readonly api: ApiService, protected navCtrl: NavController, protected router: Router, protected alerts: AlertController, protected popover: PopoverController, protected modal: ModalController, protected session: SessionService, protected loading: LoadingController, protected navService: NavigationService, protected toasts: ToastController, protected db: DatabaseService, protected editor: EditorService, protected auth: AuthService, protected opener: OpenerService, ) { } async checkNewVersion() { if ( !this.showedNewVersionAlert && await this.session.newVersionAvailable() ) { const toast = await this.toasts.create({ cssClass: 'compat-toast-container', header: 'Update Available', message: `A new version of ${this.appName} is available. Please refresh to update.`, buttons: [ { side: 'end', text: 'Refresh', handler: () => { window.location.reload(); }, }, ], }); this.showedNewVersionAlert = true; await toast.present(); } } ngOnInit() { debug('Initializing application.'); this.initializeApp(); } @HostListener('window:popstate', ['$event']) dismissModal(event) { const modal = this.modal.getTop(); if ( modal ) { event.preventDefault(); event.stopPropagation(); this.modal.dismiss(); } } showOptions($event) { this.popover.create({ event: $event, component: OptionPickerComponent, componentProps: { toggleDark: () => this.toggleDark(), isDark: () => this.isDark(), showSearch: () => this.handleKeyboardEvent(), isPrefetch: () => this.isPrefetch(), togglePrefetch: () => this.togglePrefetch(), doPrefetch: () => this.doPrefetch(), } }).then(popover => popover.present()); } @HostListener('document:keyup.control./', ['$event']) async handleKeyboardEvent() { if ( this.hasSearchOpen ) { return; } const modal = await this.modal.create({ component: SearchComponent, cssClass: 'modal-med', }); const modalState = { modal : true, desc : 'Search everything' }; history.pushState(modalState, null); this.hasSearchOpen = true; await modal.present(); await modal.onDidDismiss(); this.hasSearchOpen = false; } public navigateEditorToNode(node: any) { if ( !node.data ) { node = { data: node }; } const id = node.data.id; const nodeId = node.data.node_id; if ( !node.data.virtual ) { debug('Navigating editor to node:', {id, nodeId}); this.opener.currentPageId = id; this.opener.openTarget(id, nodeId); } } async onNodeMenuClick($event) { let canManage = this.menuTarget.data.level === 'manage'; if ( !canManage ) { if ( !this.menuTarget.data.level ) { canManage = true; } } if ( !this.menuTarget.data.id ) { return; } const options = [ {name: 'Make Virtual Root', icon: 'fa fa-search-plus', value: 'virtual_root'}, {name: 'Export to HTML', icon: 'fa fa-file-export', value: 'export_html'}, // {name: 'Export as PDF', icon: 'fa fa-file-export', value: 'export_pdf'}, ]; const manageOptions = [ {name: 'Share Sub-Tree', icon: 'fa fa-share-alt', value: 'share'}, ]; if ( this.menuTarget.data.bookmark ) { options.push({name: 'Remove Bookmark', icon: 'fa fa-star', value: 'bookmark_remove'}); } else { options.push({name: 'Bookmark', icon: 'fa fa-star', value: 'bookmark_add'}); } const popover = await this.popover.create({ component: OptionMenuComponent, componentProps: { menuItems: [ ...(!canManage ? options : [...options, ...manageOptions]), ], }, event: $event, }); popover.onDidDismiss().then((result) => { if ( result.data === 'share' ) { this.modal.create({ component: SelectorComponent, cssClass: 'modal-med', componentProps: { node: this.menuTarget.data, } }).then(modal => { const modalState = { modal : true, desc : 'Share page' }; history.pushState(modalState, null); modal.present(); }); } else if ( result.data === 'export_html' ) { this.exportTargetAsHTML(); } else if ( result.data === 'export_pdf' ) { // this.exportTargetAsPDF(); } else if ( result.data === 'virtual_root' ) { this.setVirtualRoot(); } else if ( result.data === 'bookmark_add' ) { this.addBookmark(); } else if ( result.data === 'bookmark_remove' ) { this.removeBookmark(); } }); await popover.present(); } async setVirtualRoot() { if ( this.menuTarget && this.menuTarget.data?.type === 'page' ) { debug('virtual root menu target', this.menuTarget); this.virtualRootPageId = this.menuTarget.data.id; this.reloadMenuItems().subscribe(); } } onVirtualRootClear(event) { delete this.virtualRootPageId; this.reloadMenuItems().subscribe(); } async exportTargetAsHTML() { const exportRecord: any = await new Promise((res, rej) => { const reqData = { format: 'html', PageId: this.menuTarget.data.id, }; this.api.post(`/exports/subtree`, reqData).subscribe({ next: (result) => { res(result.data); }, error: rej }); }); const dlUrl = this.api._build_url(`/exports/${exportRecord.UUID}/download`); window.open(dlUrl, '_blank'); } addBookmark() { const bookmarks = this.session.get('user.preferences.bookmark_page_ids') || []; if ( !bookmarks.includes(this.menuTarget.data.id) ) { bookmarks.push(this.menuTarget.data.id); } this.session.set('user.preferences.bookmark_page_ids', bookmarks); this.session.save().then(() => this.navService.requestSidebarRefresh({ quiet: true })); } removeBookmark() { let bookmarks = this.session.get('user.preferences.bookmark_page_ids') || []; bookmarks = bookmarks.filter(x => x !== this.menuTarget.data.id); this.session.set('user.preferences.bookmark_page_ids', bookmarks); this.session.save().then(() => this.navService.requestSidebarRefresh({ quiet: true })); } async onCreateClick($event: MouseEvent) { const menuItems = [ { name: 'Top-Level Note', icon: 'fa fa-sticky-note noded-note', value: 'top-level', title: 'Create a new top-level note page', }, ...(this.addChildTarget ? [ { name: 'Child Note', icon: 'fa fa-sticky-note noded-note', value: 'child', title: 'Create a note page as a child of the given note', }, { name: 'Form', icon: 'fa fa-clipboard-list noded-form', value: 'form', title: 'Create a new form page as a child of the given note', }, ] : []), ]; const popover = await this.popover.create({ event: $event, component: OptionMenuComponent, componentProps: { menuItems, }, }); popover.onDidDismiss().then(({ data: value }) => { if ( value === 'top-level' ) { this.onTopLevelCreate(); } else if ( value === 'child' ) { this.onChildCreate(); } else if ( value === 'form' ) { this.onChildCreate('form'); } }); await popover.present(); } async onTopLevelCreate() { const alert = await this.alerts.create({ header: 'Create Page', message: 'Please enter a new name for the page:', cssClass: 'page-prompt', inputs: [ { name: 'name', type: 'text', placeholder: 'My Awesome Page' } ], buttons: [ { text: 'Cancel', role: 'cancel', cssClass: 'secondary' }, { text: 'Create', handler: async args => { const page = await this.editor.createPage(args.name); this.reloadMenuItems().subscribe(); await this.router.navigate(['/editor', { id: page.UUID }]); } } ] }); await alert.present(); } async onChildCreate(pageType?: string) { const alert = await this.alerts.create({ header: 'Create Sub-Page', message: 'Please enter a new name for the page:', cssClass: 'page-prompt', inputs: [ { name: 'name', type: 'text', placeholder: 'My Awesome Page' } ], buttons: [ { text: 'Cancel', role: 'cancel', cssClass: 'secondary' }, { text: 'Create', handler: async args => { args = { name: args.name, parentId: this.addChildTarget.data.id, pageType, }; this.api.post('/page/create-child', args).subscribe(res => { this.reloadMenuItems().subscribe(() => { TREE_ACTIONS.EXPAND( this.lastClickEvent[0], this.lastClickEvent[1], this.lastClickEvent[2] ); this.router.navigate(['/editor', { id: res.data.UUID }]); }); }); } } ] }); await alert.present(); } async onDeleteClick() { const alert = await this.alerts.create({ header: 'Delete page?', message: 'Deleting this page will make its contents and all of its children inaccessible. Are you sure you want to continue?', buttons: [ { text: 'Keep It', role: 'cancel' }, { text: 'Delete It', handler: async () => { this.api .post(`/page/delete/${this.deleteTarget.data.id}`) .subscribe(res => { if ( this.opener.currentPageId === this.deleteTarget.data.id ) { this.router.navigate(['/home']); } this.reloadMenuItems().subscribe(); this.deleteTarget = false; this.addChildTarget = false; this.menuTarget = false; }); } } ] }); await alert.present(); } onMenuRefresh(quiet = false) { if ( !quiet ) { this.refreshingMenu = true; } this.reloadMenuItems().subscribe(); setTimeout(() => { if ( !quiet ) { this.refreshingMenu = false; } }, 2000); } reloadMenuItems() { return new Observable(sub => { this.api.getMenuItems(false, this.virtualRootPageId).then(nodes => { this.nodes = nodes; sub.next(); sub.complete(); }); }); } async initializeApp() { const initializedOnce = this.navService.initialized$.getValue(); if ( this.isDark() ) { this.toggleDark(); } debug('app', this); this.loader = await this.loading.create({ message: 'Setting things up...', cssClass: 'noded-loading-mask', showBackdrop: true, }); debug('Initializing platform and database...'); await this.loader.present(); await this.platform.ready(); await this.db.createSchemata(); let toast: any; if ( !initializedOnce ) { debug('Subscribing to offline changes...'); this.api.offline$.subscribe(async isOffline => { if ( isOffline && !this.showedOfflineAlert ) { debug('Application went offline!'); toast = await this.toasts.create({ cssClass: 'compat-toast-container', message: 'Uh, oh! It looks like you\'re offline. Some features might not work as expected...', }); this.showedOfflineAlert = true; await toast.present(); } else if ( !isOffline && this.showedOfflineAlert ) { debug('Appliation went online!'); await toast.dismiss(); this.showedOfflineAlert = false; await this.api.syncOfflineData(); } }); } debug('Getting initial status...'); let stat: any = await this.session.stat(); debug('Got stat:', stat); this.api.isPublicUser = !!stat.public_user; this.api.isAuthenticated = !!stat.authenticated_user; this.api.systemBase = stat.system_base; if ( !this.api.isAuthenticated || this.api.isPublicUser ) { debug('Unauthenticated or public user...'); if ( !this.api.isOffline ) { debug('Trying to resume session...'); await this.api.resumeSession(); debug('Checking new status...'); stat = await this.session.stat(); debug('Got session resume stat:', stat); this.api.isAuthenticated = stat.authenticated_user; this.api.isPublicUser = stat.public_user; if ( !stat.authenticated_user ) { debug('Not authenticated! Redirecting.'); window.location.href = `${stat.system_base}start`; return; } } else { debug('Unauthenticated offline user. Purging local data!'); await this.db.purge(); window.location.href = `${stat.system_base}start`; return; } } debug('Set app name and system base:', stat.app_name, stat.system_base); this.session.appName = stat.app_name; this.session.systemBase = stat.system_base; debug('Initializing session...'); await this.session.initialize(); if ( this.session.get('user.preferences.dark_mode') && !this.darkMode ) { this.toggleDark(); } debug('Hiding native splash screen & setting status bar styles...'); await this.statusBar.styleDefault(); await this.splashScreen.hide(); // If we went online after being offline, sync the local data if ( !this.api.isOffline && await this.api.needsSync() ) { this.loader.message = 'Syncing data...'; try { await this.api.syncOfflineData(); } catch (e) { this.toasts.create({ cssClass: 'compat-toast-container', message: 'An error occurred while syncing offline data. Not all data was saved.', buttons: [ 'Okay' ], }).then(tst => { tst.present(); }); } } if ( this.isPrefetch() && !this.api.isPublicUser ) { debug('Pre-fetching offline data...'); this.loader.message = 'Downloading data...'; try { await this.api.prefetchOfflineData(); } catch (e) { debug('Pre-fetch error:', e); this.toasts.create({ cssClass: 'compat-toast-container', message: 'An error occurred while pre-fetching offline data. Not all data was saved.', buttons: [ 'Okay' ], }).then(tst => { tst.present(); }); } } this.navService.initialized$.next(true); if ( !this.api.isPublicUser && this.session.get('user.preferences.default_page') ) { debug('Navigating to default page!'); const id = this.session.get('user.preferences.default_page'); const node = this.findNode(id); if ( node ) { this.navigateEditorToNode(node); } else if ( this.auth.authInProgress ) { await this.router.navigate(['/home']); } } else if ( this.auth.authInProgress ) { await this.router.navigate(['/home']); } if ( !initializedOnce ) { debug('Creating menu subscription...'); this.navService.sidebarRefresh$.subscribe(([_, quiet]) => { this.onMenuRefresh(quiet); }); this.navService.navigationRequest$.subscribe(request => { debug('Page navigation request: ', request); if ( !request.pageId ) { debug('Empty page ID. Will not navigate.'); return; } this.opener.currentPageId = request.pageId; this.router.navigate(['/editor', { id: request.pageId, ...(request.nodeId ? { node_id: request.nodeId } : {}), }]); }); this.navService.initializationRequest$.subscribe((count) => { if ( count === 0 ) { return; } this.initializeApp().then(() => { this.router.navigate(['/login']); }); }); } debug('Reloading menu items...'); this.reloadMenuItems().subscribe(() => { debug('Reloaded menu items. Displaying interface.'); this.ready$.next(true); setTimeout(() => { this.loader.dismiss(); this.menuTree?.treeModel?.expandAll(); }, 10); if ( !this.versionInterval ) { this.versionInterval = setInterval(() => { debug('Checking for new application version.'); this.checkNewVersion(); }, 1000 * 60 * 5); // Check for new version every 5 mins } }); this.auth.authInProgress = false; } async doPrefetch() { if ( this.api.isOffline ) { return; } this.loader = await this.loading.create({ message: 'Pre-fetching data...', cssClass: 'noded-loading-mask', showBackdrop: true, }); await new Promise(res => setTimeout(res, 2000)); await this.loader.present(); try { if (await this.api.needsSync()) { this.loader.message = 'Syncing data...'; await this.api.syncOfflineData(); } this.loader.message = 'Downloading data...'; await this.api.prefetchOfflineData(); } catch (e) { const msg = await this.alerts.create({ header: 'Uh, oh!', message: 'An unexpected error occurred while trying to sync offline data, and we were unable to continue.', buttons: [ 'OK', ], }); await msg.present(); } this.loader.dismiss(); } toggleDark() { // const prefersDark = window.matchMedia('(prefers-color-scheme: dark)'); this.darkMode = !this.darkMode; this.session.set('user.preferences.dark_mode', this.darkMode); document.body.classList.toggle('dark', this.darkMode); } togglePrefetch() { this.session.set('user.preferences.auto_prefetch', !this.isPrefetch()); } findNode(id: string, nodes = this.nodes) { for ( const node of nodes ) { if ( node.id === id ) { return node; } if ( node.children ) { const foundNode = this.findNode(id, node.children); if ( foundNode ) { return foundNode; } } } } isDark() { return !!this.darkMode; } isPrefetch() { return !!this.session.get('user.preferences.auto_prefetch'); } async onTreeNodeMove({ node, to }) { if ( this.api.isOffline ) { debug('Cannot move node. API is offline.'); return; } const { parent } = to; debug('Moving node:', { node, parent }); try { await this.api.moveMenuNode(node.id, to.parent.id); } catch (error) { console.error('Error moving tree node:', error); this.alerts.create({ header: 'Error Moving Node', message: error.message, buttons: [ { text: 'Okay', role: 'cancel', }, ], }).then(x => x.present()); } await this.reloadMenuItems().toPromise(); } }