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

659 lines
18 KiB

import {Component, OnInit, ViewChild, HostListener} 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';
import {debug} from './utility';
@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,
public readonly 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,
) { }
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();
}
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,
});
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 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.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() {
debug('app', this);
this.loader = await this.loading.create({
message: 'Starting 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;
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);
if ( stat.public_user ) {
this.api.isPublicUser = true;
}
if ( stat.authenticated_user ) {
this.api.isAuthenticated = true;
}
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.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.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);
}
}
debug('Creating menu subscription...');
this.navService.sidebarRefresh$.subscribe(([_, quiet]) => {
this.onMenuRefresh(quiet);
});
this.navService.navigationRequest$.subscribe(pageId => {
this.currentPageId = pageId;
this.router.navigate(['/editor', { id: pageId }]);
});
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
}
});
}
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');
}
}