Athorcis/athorrent-frontend

View on GitHub
assets/js/torrents.ts

Summary

Maintainability
C
1 day
Test Coverage
/* eslint-env browser */

import $ from 'jquery';
import Dropzone, {DropzoneFile} from 'dropzone';
import '../css/torrents.scss';
import {Router} from './core/router';
import {AbstractPage} from './core/abstract-page';
import {Application} from './core/application';
import {SecurityManager} from './core/security-manager';
import {Translator} from './core/translator';
import {UiManager} from './core/ui-manager';
import {on} from './core/events';
import { Response } from 'typescript-http-client';

const torrentListTimeout = 2000,
    trackerListTimeout = 5000;

class Updater {

    private intervalId = -1;

    private data$: AbortablePromise<unknown>;

    constructor(
        private router: Router,
        private action: string,
        private parameters: Params,
        private success: (data: string) => void,
        private interval: number) {

    }

    start(fireNow = false) {
        if (this.intervalId === -1) {
            if (fireNow) {
                this.intervalCallback();
            }

            this.intervalId = window.setInterval(this.intervalCallback.bind(this), this.interval);
        }
    }

    stop() {
        if (this.intervalId > -1) {
            clearInterval(this.intervalId);
            this.intervalId = -1;

            if (this.data$) {
                this.data$.abort();
                this.data$ = null;
            }
        }
    }

    update() {
        if (this.intervalId > -1) {
            this.stop();
            this.start(true);
        }
    }

    async intervalCallback() {
        if (this.data$) {
            this.data$.abort();
        }

        this.data$ = this.router.sendRequest(this.action, this.parameters)

        this.data$.then(this.internalSuccess.bind(this));
    }

    internalSuccess(data: string) {
        this.data$ = null;
        this.success(data);
    }

    setParameters(parameters: {[key: string]: string}) {
        this.parameters = parameters;
        this.update();
    }
}


class TabsPanel {

    private tabMap: { [key: string]: Tab };

    private panel: HTMLElement;

    private $tabs: HTMLElement;

    constructor(selector: string) {
        this.tabMap = {};
        this.panel = document.querySelector(selector);

        on(this.panel, 'click', '.nav-tabs a', this.onClick);
    }

    addTab(id: string, tab: Tab) {
        this.tabMap[id] = tab;
    }

    getCurrentTab() {
        return this.tabMap[this.panel.querySelector('.nav-tabs li.active a').getAttribute('href').substring(1)];
    }

    onClick = (event: MouseEvent) => {
        event.preventDefault();
        $(event.target).tab('show');
    }

    isVisible() {
        return this.panel.offsetParent != null;
    }

    show() {
        this.panel.style.display = 'block';
        (document.querySelector('body > .container') as HTMLElement).style.marginBottom = `${ this.panel.clientHeight }px`;
        $(this.panel).find('.nav-tabs li.active a').trigger('show.bs.tab');
    }

    hide() {
        $(this.panel).find('.nav-tabs li.active a').trigger('hide.bs.tab');
        (document.querySelector('body > .container') as HTMLElement).style.marginBottom = '';
        this.panel.style.display = 'none';
    }
}

class Tab {

    protected parent: TabsPanel;

    private $tab: JQuery;

    private container: HTMLElement;

    private updater;

    constructor(router: Router, parent: TabsPanel, id: string, action: string, parameters: Params, interval: number) {
        this.$tab = $(`[href="#${id}"]`);
        this.container = document.querySelector(`#${id}`);
        this.updater = new Updater(router, action, parameters, this.onUpdate.bind(this), interval);

        if (parent) {
            this.parent = parent;
            parent.addTab(id, this);
        }

        this.$tab.on('show.bs.tab', this.onShow.bind(this));
        this.$tab.on('hide.bs.tab', this.onHide.bind(this));
    }

    setParameters(parameters: {[key: string]: string}) {
        this.updater.setParameters(parameters);
    }

    onUpdate(data: string) {
        this.container.innerHTML = data;
    }

    onShow() {
        this.updater.start(true);
    }

    onHide() {
        this.updater.stop();
    }
}

class TorrentPanel extends TabsPanel {

    private hash: string;

    constructor() {
        super('.torrent-panel');
    }

    toggleHash(hash: string) {
        if (this.isVisible()) {
            if (this.hash === hash) {
                this.hide();
            } else {
                this.hash = hash;
                (this.getCurrentTab() as TorrentPanelTab).setHash(hash);
            }
        } else {
            this.setHash(hash);
            this.show();
        }
    }

    setHash(hash: string) {
        if (this.hash !== hash) {
            this.hash = hash;
            (this.getCurrentTab() as TorrentPanelTab).setHash(hash);
        }
    }

    getHash() {
        return this.hash;
    }
}

class TorrentPanelTab extends Tab {

    private hash: string;

    setHash(hash: string) {
        if (this.hash !== hash) {
            this.hash = hash;
            this.setParameters({ hash });
        }
    }

    onShow() {
        this.setHash((this.parent as TorrentPanel).getHash());
        super.onShow();
    }
}

class AddTorrentForm {

    private opened: boolean = false;

    private disabled: boolean;

    private form: HTMLFormElement;

    private submitEl: HTMLSpanElement;

    private mode: AddTorrentMode;

    private modes: AddTorrentMode[];

    constructor(
        selector: string,
        submitSelector: string,
        private router: Router,
        private ui: UiManager,
        private translator: Translator,
        private afterSubmit: () => void
    ) {
        this.form = document.querySelector(selector);
        this.submitEl = document.querySelector(submitSelector);
        this.modes = [];

        this.submitEl.addEventListener('click', this.onSubmitClick.bind(this));
        this.disabled = this.form.classList.contains('disabled');
    }

    onSubmitClick(event: MouseEvent) {
        const target = event.target as HTMLElement;

        if (!target.classList.contains('disabled')) {
            this.submit();
        }
    }

    isOpened() {
        return !this.opened;
    }

    open() {
        if (this.opened) {
            return;
        }

        this.opened = true;
        this.form.classList.add('opened');
    }

    close() {
        if (!this.opened) {
            return;
        }

        this.opened = false;
        this.form.classList.remove('opened');
    }

    enable() {
        if (!this.disabled) {
            return;
        }

        this.disabled = false;
        this.form.classList.remove('disabled');
    }

    disable() {
        if (this.disabled) {
            return;
        }

        this.disabled = true;
        this.form.classList.add('disabled');
    }

    setMode(mode: AddTorrentMode) {
        this.mode = mode;
    }

    getMode() {
        return this.mode;
    }

    registerMode(mode: AddTorrentMode) {
        this.modes.push(mode);
    }

    updateFileCounter() {
        let count = 0;

        for (let i = 0, { length } = this.modes; i < length; ++i) {
            count += this.modes[i].getCounter();
        }

        if (count > 0) {
            this.submitEl.classList.remove('disabled');
        } else {
            this.submitEl.classList.add('disabled');
        }
    }

    submit() {
        const params: Params = {};

        for (let i = 0, { length } = this.modes; i < length; ++i) {
            params[this.modes[i].getInputName()] = this.modes[i].getItems();
        }

        this.router.sendRequest('addTorrents', params).then(this.afterSubmit, (error: Response<{ status: string; error: string; }>) => {

            if (error.body instanceof Object) {
                this.ui.showModal('Erreur', error.body.error);
            }else {
                this.ui.showModal('Erreur', this.translator.translate('error.unknownError'))
            }
        });

        for (let i = 0, { length } = this.modes; i < length; ++i) {
            this.modes[i].clearItems();
        }

        this.mode.disable();
        this.mode = null;
    }
}

abstract class AddTorrentMode {

    private enabled: boolean = false;

    protected element: HTMLElement;

    private btn: HTMLElement;

    private counter: number = 0;

    private counterEL: HTMLElement;

    private form: AddTorrentForm;

    abstract onEnabled(): void;

    protected constructor(private inputName: string, elementSelector: string, btnSelector: string, counterSelector: string, form: AddTorrentForm) {

        this.element = document.querySelector(elementSelector);
        this.btn = document.querySelector(btnSelector);
        this.counterEL = document.querySelector(counterSelector);

        if (form) {
            this.form = form;
            form.registerMode(this);
        }

        this.btn.addEventListener('click', this.toggle.bind(this));
        $(this).on('enabled', this.onEnabled.bind(this));
    }

    enable() {
        this.enabled = true;
        this.element.style.display = 'block';
        this.btn.classList.add('active');

        if (this.form.isOpened()) {
            this.form.open();
        } else {
            this.form.getMode().disable(true);
        }

        this.form.setMode(this);

        $(this).trigger('enabled');
    }

    disable(recursive = false) {
        this.enabled = false;
        this.element.style.display = 'none';
        this.btn.classList.remove('active');

        if (!recursive) {
            this.form.close();
            this.form.setMode(null);
        }
    }

    toggle() {
        if (this.enabled) {
            this.disable();
        } else {
            this.enable();
        }
    }

    setCounter(number: number) {
        this.counter = number;
        this.counterEL.textContent = `(${number})`;
        this.form.updateFileCounter();
    }

    getCounter() {
        return this.counter;
    }

    getInputName(): string {
        return this.inputName;
    }

    abstract getItems(): string[];
    abstract clearItems(): void;
}

class AddTorrentFileMode extends AddTorrentMode {

    private dropzone;

    constructor(
        router: Router,
        translator: Translator,
        ui: UiManager,
        private securityManager: SecurityManager,
        inputName: string,
        elementSelector: string,
        btnSelector: string,
        counterSelector: string,
        form: AddTorrentForm
    ) {
        super(inputName, elementSelector, btnSelector, counterSelector, form);


        this.dropzone = new Dropzone(elementSelector, {
            url: router.generateUrl('uploadTorrent'),
            paramName: 'upload-torrent-file',
            dictDefaultMessage: translator.translate('torrents.dropzone'),
            dictInvalidFileType: translator.translate('error.notATorrent'),
            dictFileTooBig: translator.translate('error.fileTooBig'),
            dictResponseError: translator.translate('error.serverError'),
            previewTemplate: document.querySelector('#template-dropzone-preview').innerHTML,
            acceptedFiles: '.torrent',
            parallelUploads: 1,
            maxFilesize: 1
        });

        this.dropzone.on('removedfile', this.onRemovedFile.bind(this));
        this.dropzone.on('success', this.onSuccess.bind(this));
        this.dropzone.on('error', this.onError.bind(this));

        this.dropzone.on('sending', this.onSending.bind(this));
    }

    onEnabled() {
        this.element.dispatchEvent(new MouseEvent('click'));
    }

    onRemovedFile() {
        this.setCounter(this.dropzone.getAcceptedFiles().length);
    }

    onSuccess(file: DropzoneFile[], result: ApiResponse<unknown>) {
        this.securityManager.setCsrfToken(result.csrfToken);
        this.setCounter(this.dropzone.getAcceptedFiles().length);
    }

    onError(file: DropzoneFile[], result: ApiResponse<unknown>) {
        if (typeof result === 'object' && result.hasOwnProperty('csrfToken')) {
            this.securityManager.setCsrfToken(result.csrfToken);
        }
    }

    onSending(file: DropzoneFile[], xhr: XMLHttpRequest, formData: FormData) {
        formData.append('csrfToken', this.securityManager.getCsrfToken());
    }

    getItems() {
        const items = [],
            files = this.dropzone.getAcceptedFiles();

        for (let i = 0, { length } = files; i < length; ++i) {
            items.push(files[i].name);
        }

        return items;
    }

    clearItems() {
        this.dropzone.removeAllFiles(true);
        this.setCounter(0);
    }
}

class AddTorrentMagnetMode extends AddTorrentMode {

    private textarea: HTMLTextAreaElement;

    constructor(inputName: string, elementSelector: string, btnSelector: string, counterSelector: string, form: AddTorrentForm) {
        super(inputName, elementSelector, btnSelector, counterSelector, form);

        this.textarea = document.querySelector('#add-torrent-magnet-input');
        this.textarea.addEventListener('input', this.onInput.bind(this));
    }

    onEnabled() {
        this.element.querySelector('textarea').focus();
    }

    onInput() {
        this.setCounter(this.getItems().length);
    }

    getItems() {
        const magnets = [],
            rmagnet = /^magnet:\?[\x20-\x7E]*/,
            lines = this.textarea.value.split(/\r\n|\r|\n/);

        for (let i = 0, { length } = lines; i < length; ++i) {
            if (rmagnet.test(lines[i])) {
                magnets.push(lines[i]);
            }
        }

        return magnets;
    }

    clearItems() {
        this.textarea.value = '';
        this.setCounter(0);
    }
}

class TorrentsPage extends AbstractPage {

    private torrentsUpdater: Updater;

    private torrentPanel: TorrentPanel;

    private trackersTab: TorrentPanelTab;

    private addTorrentFileMode: AddTorrentFileMode;

    private addTorrentMagnetMode: AddTorrentMagnetMode;

    private addTorrentForm: AddTorrentForm;

    init() {

        this.initializeTorrentsList();
        this.initializeTorrentPanel();
        this.initializeAddTorrentForm();

        if (navigator.registerProtocolHandler) {
            navigator.registerProtocolHandler('magnet', `${ location.origin }/user/torrents/magnet?magnet=%s`, 'Athorrent');
        }
    }

    getTorrentHash(element: HTMLElement) {
        return this.getItemId('torrent', element);
    }

    onUpdateTorrents = (data: string) => {
        document.querySelector('.torrent-list').innerHTML = data;

        if (document.querySelector('.client-updating-warning')) {
            this.addTorrentForm.disable();
        }
        else {
            this.addTorrentForm.enable();
        }
    }

    protected async applyActionToTorrent(action: string, element: HTMLElement) {
        await this.sendRequest(action, {
            hash: this.getTorrentHash(element)
        });

        this.torrentsUpdater.update();
    }

    onTorrentPause = async (event: MouseEvent) => {
        return this.applyActionToTorrent('pauseTorrent', event.target as HTMLElement);
    }

    onTorrentResume = async (event: MouseEvent) =>  {
        return this.applyActionToTorrent('resumeTorrent', event.target as HTMLElement);
    }

    onTorrentRemove = async (event: MouseEvent) => {
        return this.applyActionToTorrent('removeTorrent', event.target as HTMLElement);
    }

    initializeTorrentsList() {
        this.torrentsUpdater = new Updater(this.router,'listTorrents', {}, this.onUpdateTorrents, torrentListTimeout);
        this.torrentsUpdater.start();

        on(document, 'click', new Map([
            ['.torrent-pause', this.onTorrentPause],
            ['.torrent-resume', this.onTorrentResume],
            ['.torrent-remove', this.onTorrentRemove]
        ]));
    }

    onShowDetails = (event: MouseEvent) => {
        this.torrentPanel.toggleHash(this.getTorrentHash(event.target as HTMLElement));
    }

    initializeTorrentPanel() {
        this.torrentPanel = new TorrentPanel();
        this.trackersTab = new TorrentPanelTab(this.router, this.torrentPanel, 'torrent-trackers', 'listTrackers', {}, trackerListTimeout);

        on(document, 'click', '.torrent-detail', this.onShowDetails);
    }

    initializeAddTorrentForm() {
        this.addTorrentForm = new AddTorrentForm('#add-torrent-form',
            '#add-torrent-submit',
            this.router,
            this.ui,
            this.translator,
            () => {
                this.torrentsUpdater.update();
            }
        );

        this.addTorrentFileMode = new AddTorrentFileMode(this.router, this.translator, this.ui, this.securityManager, 'add-torrent-files', '#add-torrent-file-drop', '#add-torrent-file', '#add-torrent-file-counter', this.addTorrentForm);
        this.addTorrentMagnetMode = new AddTorrentMagnetMode('add-torrent-magnets', '#add-torrent-magnet-wrapper', '#add-torrent-magnet', '#add-torrent-magnet-counter', this.addTorrentForm);
    }
}

Application.create().run(TorrentsPage);