You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
frontend/src/app/app.component.ts

598 lines
16 KiB

import {AfterViewInit, Component, ElementRef, OnInit, ViewChild, HostListener, Host} from '@angular/core';
import {
AlertController,
ModalController,
Platform,
PopoverController,
LoadingController,
ToastController
} 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, 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';
@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<boolean> = new BehaviorSubject<boolean>(false);
public addChildTarget: any = false;
public deleteTarget: any = false;
public menuTarget: any = false;
public refreshingMenu = false;
public lastClickEvent: Array<any> = [];
public nodes = [];
public currentPageId: string;
public options = {
isExpandedField: 'expanded',
animateExpand: true,
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];
}
}
}
};
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 initialized$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
constructor(
private platform: Platform,
private splashScreen: SplashScreen,
private statusBar: StatusBar,
private api: ApiService,
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,
) {
this.initializeApp();
}
async _doInit() {
await this.db.createSchemata();
this.reloadMenuItems().subscribe(() => {
this.ready$.next(true);
setTimeout(() => {
this.loader.dismiss();
this.menuTree.treeModel.expandAll();
}, 10);
if ( !this.versionInterval ) {
this.versionInterval = setInterval(() => {
this.checkNewVersion();
}, 1000 * 60 * 5); // Check for new version every 5 mins
}
});
}
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() {
if ( !this.initialized$.getValue() ) {
this.navService.sidebarRefresh$.subscribe(([_, quiet]) => {
this.onMenuRefresh(quiet);
});
const sub = this.initialized$.subscribe((didInit) => {
if (didInit) {
this._doInit();
sub.unsubscribe();
}
});
} else {
this._doInit();
}
}
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());
}
onFilterChange($event) {
const query = $event.detail.value.toLowerCase();
this.menuTree.treeModel.clearFilter();
if ( query ) {
this.menuTree.treeModel.filterNodes(node => {
if ( node.data.virtual ) {
// "Virtual" tree nodes should always be shown
return true;
}
return node.data.name.toLowerCase().indexOf(query) >= 0;
});
}
}
@HostListener('document:keyup.control./', ['$event'])
async handleKeyboardEvent() {
if ( this.hasSearchOpen ) {
return;
}
const modal = await this.modal.create({
component: SearchComponent,
});
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 ) {
this.currentPageId = id;
this.router.navigate(['/editor', { id, ...(nodeId ? { node_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: 'Export to HTML', icon: 'fa fa-file-export', value: 'export_html'},
];
const manageOptions = [
{name: 'Share Sub-Tree', icon: 'fa fa-share-alt', value: 'share'},
];
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,
componentProps: {
node: this.menuTarget.data,
}
}).then(modal => {
modal.present();
});
} else if ( result.data === 'export_html' ) {
this.exportTargetAsHTML();
};
});
await popover.present();
}
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');
}
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() {
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
};
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.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().then(nodes => {
this.nodes = nodes;
sub.next();
sub.complete();
});
});
}
async initializeApp() {
console.log('app', this);
this.loader = await this.loading.create({
message: 'Starting up...',
cssClass: 'noded-loading-mask',
showBackdrop: true,
});
await this.loader.present();
await this.platform.ready();
let toast: any;
this.api.offline$.subscribe(async isOffline => {
if ( isOffline && !this.showedOfflineAlert ) {
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 ) {
await toast.dismiss();
this.showedOfflineAlert = false;
await this.api.syncOfflineData();
}
});
let stat: any = await this.session.stat();
if ( !stat.authenticated_user ) {
if ( !this.api.isOffline ) {
await this.api.resumeSession();
stat = await this.session.stat();
if ( !stat.authenticated_user ) {
window.location.href = `${stat.system_base}start`;
return;
}
} else {
await this.db.purge();
window.location.href = `${stat.system_base}start`;
return;
}
}
this.session.appName = stat.app_name;
this.session.systemBase = stat.system_base;
await this.session.initialize();
if ( this.session.get('user.preferences.dark_mode') ) {
this.toggleDark();
}
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.loader.message = 'Downloading data...'; // TODO actually do the prefetch
try {
await this.api.prefetchOfflineData();
} catch (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.initialized$.next(true);
if ( this.session.get('user.preferences.default_page') ) {
const id = this.session.get('user.preferences.default_page');
const node = this.findNode(id);
if ( node ) {
this.navigateEditorToNode(node);
}
}
}
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');
}
}