|
|
|
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;
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
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');
|
|
|
|
}
|
|
|
|
}
|