From 0689c984ff690a2b09ebf33a2efb4f2e008be73c Mon Sep 17 00:00:00 2001 From: Eugene Pankov Date: Fri, 25 Jun 2021 21:55:40 +0200 Subject: [PATCH] wip --- poetry.lock | 74 ++++++++++++++++- pyproject.toml | 1 + src/api.ts | 19 +++++ src/app.module.ts | 19 +++-- src/components/app.component.ts | 4 +- src/components/configModal.component.pug | 40 ++++++++++ src/components/configModal.component.ts | 29 +++++++ src/components/main.component.pug | 66 ++------------- src/components/main.component.ts | 93 +++++++++------------- src/components/settingsModal.component.pug | 32 ++++++++ src/components/settingsModal.component.ts | 36 +++++++++ src/services/appConnector.service.ts | 11 ++- src/services/config.service.ts | 83 +++++++++++++++++++ src/services/login.service.ts | 15 +++- src/terminal.ts | 52 +++--------- src/theme/index.scss | 6 +- src/theme/vars.scss | 9 ++- terminus/app/api.py | 15 ++-- terminus/app/sponsors.py | 63 +++++++++++++++ terminus/app/urls.py | 2 +- terminus/settings.py | 12 +++ webpack.config.js | 73 ++++++++++++++++- webpack.main.config.js | 67 ---------------- webpack.terminal.config.js | 72 ----------------- 24 files changed, 573 insertions(+), 320 deletions(-) create mode 100644 src/api.ts create mode 100644 src/components/configModal.component.pug create mode 100644 src/components/configModal.component.ts create mode 100644 src/components/settingsModal.component.pug create mode 100644 src/components/settingsModal.component.ts create mode 100644 src/services/config.service.ts create mode 100644 terminus/app/sponsors.py delete mode 100644 webpack.main.config.js delete mode 100644 webpack.terminal.config.js diff --git a/poetry.lock b/poetry.lock index 68eefdb..03edad9 100644 --- a/poetry.lock +++ b/poetry.lock @@ -210,6 +210,41 @@ mccabe = ">=0.6.0,<0.7.0" pycodestyle = ">=2.7.0,<2.8.0" pyflakes = ">=2.3.0,<2.4.0" +[[package]] +name = "gql" +version = "2.0.0" +description = "GraphQL client for Python" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +graphql-core = ">=2.3.2,<3" +promise = ">=2.3,<3" +requests = ">=2.12,<3" +six = ">=1.10.0" + +[package.extras] +dev = ["flake8 (==3.8.1)", "isort (==4.3.21)", "black (==19.10b0)", "mypy (==0.770)", "check-manifest (>=0.42,<1)", "pytest (==5.4.2)", "pytest-asyncio (==0.11.0)", "pytest-cov (==2.8.1)", "mock (==4.0.2)", "vcrpy (==4.0.2)", "coveralls (==2.0.0)"] +test = ["pytest (==5.4.2)", "pytest-asyncio (==0.11.0)", "pytest-cov (==2.8.1)", "mock (==4.0.2)", "vcrpy (==4.0.2)", "coveralls (==2.0.0)"] + +[[package]] +name = "graphql-core" +version = "2.3.2" +description = "GraphQL implementation for Python" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +promise = ">=2.3,<3" +rx = ">=1.6,<2" +six = ">=1.10.0" + +[package.extras] +gevent = ["gevent (>=1.1)"] +test = ["six (==1.14.0)", "pyannotate (==1.2.0)", "pytest (==4.6.10)", "pytest-django (==3.9.0)", "pytest-cov (==2.8.1)", "coveralls (==1.11.1)", "cython (==0.29.17)", "gevent (==1.5.0)", "pytest-benchmark (==3.2.3)", "pytest-mock (==2.0.0)"] + [[package]] name = "hyperlink" version = "21.0.0" @@ -277,6 +312,20 @@ rsa = ["cryptography (>=3.0.0,<4)"] signals = ["blinker (>=1.4.0)"] signedtoken = ["cryptography (>=3.0.0,<4)", "pyjwt (>=2.0.0,<3)"] +[[package]] +name = "promise" +version = "2.3" +description = "Promises/A+ implementation for Python" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +six = "*" + +[package.extras] +test = ["pytest (>=2.7.3)", "pytest-cov", "coveralls", "futures", "pytest-benchmark", "mock"] + [[package]] name = "pyasn1" version = "0.4.8" @@ -411,6 +460,14 @@ requests = ">=2.0.0" [package.extras] rsa = ["oauthlib[signedtoken] (>=3.0.0)"] +[[package]] +name = "rx" +version = "1.6.1" +description = "Reactive Extensions (Rx) for Python" +category = "main" +optional = false +python-versions = "*" + [[package]] name = "service-identity" version = "21.1.0" @@ -596,7 +653,7 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"] [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "8e1e4f30b4d5f3cba3dc4b594a872f149b9aa3eeb6d3b5b721f5847c6aa2fd84" +content-hash = "7ddd4096097eb58dc20601d3334a56507c893607ff156155070c4c865108c563" [metadata.files] asgiref = [ @@ -715,6 +772,14 @@ flake8 = [ {file = "flake8-3.9.2-py2.py3-none-any.whl", hash = "sha256:bf8fd333346d844f616e8d47905ef3a3384edae6b4e9beb0c5101e25e3110907"}, {file = "flake8-3.9.2.tar.gz", hash = "sha256:07528381786f2a6237b061f6e96610a4167b226cb926e2aa2b6b1d78057c576b"}, ] +gql = [ + {file = "gql-2.0.0-py2.py3-none-any.whl", hash = "sha256:35032ddd4bfe6b8f3169f806b022168932385d751eacc5c5f7122e0b3f4d6b88"}, + {file = "gql-2.0.0.tar.gz", hash = "sha256:fe8d3a08047f77362ddfcfddba7cae377da2dd66f5e61c59820419c9283d4fb5"}, +] +graphql-core = [ + {file = "graphql-core-2.3.2.tar.gz", hash = "sha256:aac46a9ac524c9855910c14c48fc5d60474def7f99fd10245e76608eba7af746"}, + {file = "graphql_core-2.3.2-py2.py3-none-any.whl", hash = "sha256:44c9bac4514e5e30c5a595fac8e3c76c1975cae14db215e8174c7fe995825bad"}, +] hyperlink = [ {file = "hyperlink-21.0.0-py2.py3-none-any.whl", hash = "sha256:e6b14c37ecb73e89c77d78cdb4c2cc8f3fb59a885c5b3f819ff4ed80f25af1b4"}, {file = "hyperlink-21.0.0.tar.gz", hash = "sha256:427af957daa58bc909471c6c40f74c5450fa123dd093fc53efd2e91d2705a56b"}, @@ -739,6 +804,9 @@ oauthlib = [ {file = "oauthlib-3.1.1-py2.py3-none-any.whl", hash = "sha256:42bf6354c2ed8c6acb54d971fce6f88193d97297e18602a3a886603f9d7730cc"}, {file = "oauthlib-3.1.1.tar.gz", hash = "sha256:8f0215fcc533dd8dd1bee6f4c412d4f0cd7297307d43ac61666389e3bc3198a3"}, ] +promise = [ + {file = "promise-2.3.tar.gz", hash = "sha256:dfd18337c523ba4b6a58801c164c1904a9d4d1b1747c7d5dbf45b693a49d93d0"}, +] pyasn1 = [ {file = "pyasn1-0.4.8-py2.4.egg", hash = "sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3"}, {file = "pyasn1-0.4.8-py2.5.egg", hash = "sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf"}, @@ -810,6 +878,10 @@ requests-oauthlib = [ {file = "requests_oauthlib-1.3.0-py2.py3-none-any.whl", hash = "sha256:7f71572defaecd16372f9006f33c2ec8c077c3cfa6f5911a9a90202beb513f3d"}, {file = "requests_oauthlib-1.3.0-py3.7.egg", hash = "sha256:fa6c47b933f01060936d87ae9327fead68768b69c6c9ea2109c48be30f2d4dbc"}, ] +rx = [ + {file = "Rx-1.6.1-py2.py3-none-any.whl", hash = "sha256:7357592bc7e881a95e0c2013b73326f704953301ab551fbc8133a6fadab84105"}, + {file = "Rx-1.6.1.tar.gz", hash = "sha256:13a1d8d9e252625c173dc795471e614eadfe1cf40ffc684e08b8fff0d9748c23"}, +] service-identity = [ {file = "service-identity-21.1.0.tar.gz", hash = "sha256:6e6c6086ca271dc11b033d17c3a8bea9f24ebff920c587da090afc9519419d34"}, {file = "service_identity-21.1.0-py2.py3-none-any.whl", hash = "sha256:f0b0caac3d40627c3c04d7a51b6e06721857a0e10a8775f2d1d7e72901b3a7db"}, diff --git a/pyproject.toml b/pyproject.toml index c10021d..16a9ef7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ djangorestframework-dataclasses = "^0.9" social-auth-app-django = "^4.0.0" python-dotenv = "^0.17.1" websockets = "^9.1" +gql = "^2.0.0" [tool.poetry.dev-dependencies] flake8 = "^3.9.2" diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 0000000..c105f27 --- /dev/null +++ b/src/api.ts @@ -0,0 +1,19 @@ +export interface User { + active_config: number + active_version: string + custom_connection_gateway: string|null + custom_connection_gateway_token: string|null + is_pro: boolean +} + +export interface Config { + id: number + content: string + last_used_with_version: string + created_at: Date + modified_at: Date +} + +export interface Version { + version: string +} diff --git a/src/app.module.ts b/src/app.module.ts index 2abb4c7..4ae70e4 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,24 +1,33 @@ -import { NgModule } from '@angular/core'; -import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap' -import { BrowserModule } from '@angular/platform-browser'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { NgModule } from '@angular/core' +import { NgbDropdownModule, NgbModalModule } from '@ng-bootstrap/ng-bootstrap' +import { BrowserModule } from '@angular/platform-browser' +import { BrowserAnimationsModule } from '@angular/platform-browser/animations' +import { CommonModule } from '@angular/common' +import { FormsModule } from '@angular/forms' import { HttpClientModule, HttpClientXsrfModule } from '@angular/common/http' import { FontAwesomeModule } from '@fortawesome/angular-fontawesome' import { AppComponent } from './components/app.component' -import { MainComponent } from './components/main.component'; +import { MainComponent } from './components/main.component' +import { ConfigModalComponent } from './components/configModal.component' +import { SettingsModalComponent } from './components/settingsModal.component' @NgModule({ imports: [ BrowserModule, BrowserAnimationsModule, + CommonModule, + FormsModule, HttpClientModule, HttpClientXsrfModule, NgbDropdownModule, + NgbModalModule, FontAwesomeModule, ], declarations: [ AppComponent, MainComponent, + ConfigModalComponent, + SettingsModalComponent, ], bootstrap: [AppComponent] }) diff --git a/src/components/app.component.ts b/src/components/app.component.ts index 264c2c7..dc7257c 100644 --- a/src/components/app.component.ts +++ b/src/components/app.component.ts @@ -21,7 +21,9 @@ export class AppComponent { constructor ( private loginService: LoginService, - ) { } + ) { + this.providers = [this.providers[0]] // only keep GH for now + } async ngOnInit () { await this.loginService.ready$.toPromise() diff --git a/src/components/configModal.component.pug b/src/components/configModal.component.pug new file mode 100644 index 0000000..57889b8 --- /dev/null +++ b/src/components/configModal.component.pug @@ -0,0 +1,40 @@ +.modal-body + .header(*ngIf='configService.activeConfig') + .d-flex.align-items-center.py-2.px-4 + .me-auto + label Active config + .title {{configService.activeConfig.modified_at}} + + button.btn.btn-semi.me-2((click)='configService.duplicateConfig()') + fa-icon([icon]='_copyIcon', [fixedWidth]='true') + + button.btn.btn-semi((click)='deleteConfig()') + fa-icon([icon]='_deleteIcon', [fixedWidth]='true') + + .d-flex.align-items-center.py-2.px-4(*ngIf='configService.activeVersion') + .me-auto App version: + div(ngbDropdown) + button.btn.btn-semi(ngbDropdownToggle) {{configService.activeVersion.version}} + div(ngbDropdownMenu) + a( + *ngFor='let version of versions', + ngbDropdownItem, + [class.active]='version == configService.activeVersion', + (click)='selectVersion(version)' + ) {{version.version}} + + div(*ngIf='configService.configs.length > 1') + .dropdown-header All configs + + ng-container(*ngFor='let config of configService.configs') + a( + *ngIf='config !== configService.activeConfig', + ngbDropdownItem, + (click)='selectConfig(config)', + href='#' + ) Config modified at {{config.modified_at}} + + .p-3 + button.btn.btn-semi.w-100((click)='configService.createNewConfig()') + fa-icon([icon]='_addIcon', [fixedWidth]='true') + span New config diff --git a/src/components/configModal.component.ts b/src/components/configModal.component.ts new file mode 100644 index 0000000..072a41c --- /dev/null +++ b/src/components/configModal.component.ts @@ -0,0 +1,29 @@ +import { Component } from '@angular/core' +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' +import { AppConnectorService } from '../services/appConnector.service' +import { ConfigService } from '../services/config.service' +import { faPlus } from '@fortawesome/free-solid-svg-icons' + +@Component({ + selector: 'config-modal', + templateUrl: './configModal.component.pug', + // styleUrls: ['./settingsModal.component.scss'], +}) +export class ConfigModalComponent { + _addIcon = faPlus + + constructor ( + private modalInstance: NgbActiveModal, + public appConnector: AppConnectorService, + public configService: ConfigService, + ) { + } + + async ngOnInit () { + } + + cancel () { + this.modalInstance.dismiss() + } + +} diff --git a/src/components/main.component.pug b/src/components/main.component.pug index bd4a88e..aac01a1 100644 --- a/src/components/main.component.pug +++ b/src/components/main.component.pug @@ -1,66 +1,14 @@ .sidebar img.logo(src='{{_logo}}') - div(ngbDropdown, placement='bottom-right') - button.btn(ngbDropdownToggle) - fa-icon([icon]='_cogIcon', [fixedWidth]='true') + button.btn((click)='openConfig()') + fa-icon([icon]='_cogIcon', [fixedWidth]='true') - .config-menu(ngbDropdownMenu) - .header(*ngIf='getActiveConfig()') - .d-flex.align-items-center.py-2.px-4 - .me-auto - label Active config - .title {{getActiveConfig().modified_at}} + button.btn((click)='openSettings()') + fa-icon([icon]='_settingsIcon', [fixedWidth]='true') - button.btn.btn-semi.me-2((click)='duplicateConfig()') - fa-icon([icon]='_copyIcon', [fixedWidth]='true') + button.btn.mt-auto((click)='logout()') + fa-icon([icon]='_logoutIcon', [fixedWidth]='true') - button.btn.btn-semi((click)='deleteConfig()') - fa-icon([icon]='_deleteIcon', [fixedWidth]='true') - - .d-flex.align-items-center.py-2.px-4(*ngIf='activeVersion') - .me-auto App version: - div(ngbDropdown) - button.btn.btn-semi(ngbDropdownToggle) {{activeVersion.version}} - div(ngbDropdownMenu) - a( - *ngFor='let version of versions', - ngbDropdownItem, - [class.active]='version == activeVersion', - (click)='selectVersion(version)' - ) {{version.version}} - - div(*ngIf='configs.length > 1') - .dropdown-header All configs - - ng-container(*ngFor='let config of configs') - a( - *ngIf='config !== getActiveConfig()', - ngbDropdownItem, - (click)='selectConfig(config)', - href='#' - ) Config modified at {{config.modified_at}} - - .p-3 - button.btn.btn-semi.w-100((click)='createNewConfig()') - fa-icon([icon]='_addIcon', [fixedWidth]='true') - span New config - - div(ngbDropdown, placement='bottom-right') - button.btn(ngbDropdownToggle) - fa-icon([icon]='_connectionIcon', [fixedWidth]='true') - - div(ngbDropdownMenu) - .form-check.form-switch - input.form-check-input(type='checkbox', ngModel='!!loginService.user.custom_connection_gateway') - label(class='form-check-label') Use custom connection gateway - - div(ngbDropdown, placement='bottom-right') - button.btn(ngbDropdownToggle) - fa-icon([icon]='_userIcon', [fixedWidth]='true') - - div(ngbDropdownMenu) - a(ngbDropdownItem, (click)='logout()', href='#') Logout - -.terminal([hidden]='!activeVersion') +.terminal([hidden]='!showApp') iframe(#iframe) diff --git a/src/components/main.component.ts b/src/components/main.component.ts index 76300f6..95ca649 100644 --- a/src/components/main.component.ts +++ b/src/components/main.component.ts @@ -1,10 +1,15 @@ -import * as semverGT from 'semver/functions/gt' import { Component, ElementRef, ViewChild } from '@angular/core' import { HttpClient } from '@angular/common/http' import { AppConnectorService } from '../services/appConnector.service' -import { faCog, faUser, faCopy, faTrash, faPlus, faPlug } from '@fortawesome/free-solid-svg-icons' +import { faCog, faCopy, faTrash, faPlus, faSignOutAlt } from '@fortawesome/free-solid-svg-icons' import { LoginService } from '../services/login.service' +import { NgbModal } from '@ng-bootstrap/ng-bootstrap' +import { SettingsModalComponent } from './settingsModal.component' +import { ConfigModalComponent } from './configModal.component' +import { ConfigService } from '../services/config.service' +import { combineLatest } from 'rxjs' +import { Config, Version } from '../api' @Component({ selector: 'main', @@ -14,21 +19,22 @@ import { LoginService } from '../services/login.service' export class MainComponent { _logo = require('../assets/logo.svg') _cogIcon = faCog - _userIcon = faUser + _settingsIcon = faCog + _logoutIcon = faSignOutAlt _copyIcon = faCopy _addIcon = faPlus _deleteIcon = faTrash - _connectionIcon = faPlug - configs: any[] = [] - versions: any[] = [] - activeVersion?: any + showApp = false + @ViewChild('iframe') iframe: ElementRef constructor ( - private appConnector: AppConnectorService, + public appConnector: AppConnectorService, private http: HttpClient, public loginService: LoginService, + private ngbModal: NgbModal, + private config: ConfigService, ) { window.addEventListener('message', event => { if (event.data === 'request-connector') { @@ -39,68 +45,47 @@ export class MainComponent { } async ngAfterViewInit () { - this.configs = await this.http.get('/api/1/configs').toPromise() - this.versions = await this.http.get('/api/1/versions').toPromise() - - this.versions.sort((a, b) => semverGT(a, b)) - - if (!this.configs.length) { - await this.createNewConfig() - } - - this.selectConfig(this.configs[0]) - } - - async createNewConfig () { - this.configs.push(await this.http.post('/api/1/configs', { - content: '{}', - last_used_with_version: this.versions[0].version, - }).toPromise()) - } - - async duplicateConfig () { - const copy = {...this.appConnector.config, pk: undefined} - this.configs.push(await this.http.post('/api/1/configs', copy).toPromise()) + combineLatest( + this.config.activeConfig$, + this.config.activeVersion$ + ).subscribe(([config, version]) => { + if (config && version) { + this.reloadApp(config, version) + } + }) + this.config + await this.config.ready$.toPromise() + await this.config.selectDefaultConfig() } unloadApp () { - delete this.activeVersion + this.showApp = false this.iframe.nativeElement.src = 'about:blank' } - loadApp (version) { + async loadApp (config, version) { + this.showApp = true this.iframe.nativeElement.src = '/terminal' - this.activeVersion = version + await this.http.patch(`/api/1/configs/${config.id}`, { + last_used_with_version: version.version, + }).toPromise() } - getActiveConfig () { - return this.appConnector.config - } - - selectVersion (version: any) { + reloadApp (config: Config, version: Version) { // TODO check config incompatibility this.unloadApp() setTimeout(() => { - this.appConnector.version = version - this.loadApp(version) + this.appConnector.setState(config, version) + this.loadApp(config, version) }) } - async selectConfig (config: any) { - let matchingVersion = this.versions.find(x => x.version === config.last_used_with_version) - if (!matchingVersion) { - // TODO ask to upgrade - matchingVersion = this.versions[0] - } + async openConfig () { + await this.ngbModal.open(ConfigModalComponent).result + } - this.appConnector.config = config - - const result = await this.http.patch(`/api/1/configs/${config.id}`, { - last_used_with_version: matchingVersion.version, - }).toPromise() - Object.assign(config, result) - - this.selectVersion(matchingVersion) + async openSettings () { + await this.ngbModal.open(SettingsModalComponent).result } async logout () { diff --git a/src/components/settingsModal.component.pug b/src/components/settingsModal.component.pug new file mode 100644 index 0000000..0bc395b --- /dev/null +++ b/src/components/settingsModal.component.pug @@ -0,0 +1,32 @@ +.modal-header + h5.modal-title Settings +.modal-body + .mb-3 + .form-check.form-switch + input.form-check-input( + type='checkbox', + [(ngModel)]='customGatewayEnabled' + ) + label(class='form-check-label') Use custom connection gateway + + .mb-3(*ngIf='customGatewayEnabled') + .form-floating + input.form-control( + type='text', + [(ngModel)]='user.custom_connection_gateway', + placeholder='wss://1.2.3.4' + ) + label Gateway address + + .mb-3(*ngIf='customGatewayEnabled') + .form-floating + input.form-control( + type='password', + [(ngModel)]='user.custom_connection_gateway_token', + placeholder='123' + ) + label Gateway authentication token + +.modal-footer + button.btn.btn-primary((click)='apply()') Apply + button.btn.btn-secondary((click)='cancel()') Cancel diff --git a/src/components/settingsModal.component.ts b/src/components/settingsModal.component.ts new file mode 100644 index 0000000..0f4051d --- /dev/null +++ b/src/components/settingsModal.component.ts @@ -0,0 +1,36 @@ +import { Component } from '@angular/core' +import { LoginService } from '../services/login.service' + +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' +import { User } from '../api' + +@Component({ + selector: 'settings-modal', + templateUrl: './settingsModal.component.pug', + // styleUrls: ['./settingsModal.component.scss'], +}) +export class SettingsModalComponent { + user: User + customGatewayEnabled = false + + constructor ( + private modalInstance: NgbActiveModal, + private loginService: LoginService, + ) { + this.user = { ...loginService.user } + this.customGatewayEnabled = !!this.user.custom_connection_gateway + } + + async ngOnInit () { + } + + async apply () { + Object.assign(this.loginService.user, this.user) + this.modalInstance.close() + await this.loginService.updateUser() + } + + cancel () { + this.modalInstance.dismiss() + } +} diff --git a/src/services/appConnector.service.ts b/src/services/appConnector.service.ts index 69d7a02..9ec831c 100644 --- a/src/services/appConnector.service.ts +++ b/src/services/appConnector.service.ts @@ -4,6 +4,7 @@ import { debounceTime } from 'rxjs/operators' import { HttpClient } from '@angular/common/http' import { Injectable } from '@angular/core' import { LoginService } from '../services/login.service' +import { Config, Version } from '../api' export class SocketProxy { connect$ = new Subject() @@ -86,10 +87,9 @@ export class SocketProxy { @Injectable({ providedIn: 'root' }) export class AppConnectorService { - config: any - version: any - user: any private configUpdate = new Subject() + private config: Config + private version: Version constructor ( private http: HttpClient, @@ -101,6 +101,11 @@ export class AppConnectorService { }) } + setState (config: Config, version: Version) { + this.config = config + this.version = version + } + async loadConfig (): Promise { return this.config.content } diff --git a/src/services/config.service.ts b/src/services/config.service.ts new file mode 100644 index 0000000..4f05b49 --- /dev/null +++ b/src/services/config.service.ts @@ -0,0 +1,83 @@ +import * as semverGT from 'semver/functions/gt' +import { AsyncSubject, Subject } from 'rxjs' +import { HttpClient } from '@angular/common/http' +import { Injectable } from '@angular/core' +import { Config, User, Version } from '../api' +import { LoginService } from './login.service' + + +@Injectable({ providedIn: 'root' }) +export class ConfigService { + activeConfig$ = new Subject() + activeVersion$ = new Subject() + user: User + + configs: Config[] = [] + versions: Version[] = [] + ready$ = new AsyncSubject() + + get activeConfig (): Config { return this._activeConfig } + get activeVersion (): Version { return this._activeVersion } + + private _activeConfig: Config|null = null + private _activeVersion: Version|null = null + + constructor ( + private http: HttpClient, + private loginService: LoginService, + ) { + this.init() + } + + async updateUser () { + await this.http.put('/api/1/user', this.user).toPromise() + } + + async createNewConfig () { + this.configs.push(await this.http.post('/api/1/configs', { + content: '{}', + last_used_with_version: this._activeVersion.version, + }).toPromise()) + } + + async duplicateActiveConfig () { + const copy = {...this._activeConfig, pk: undefined} + this.configs.push(await this.http.post('/api/1/configs', copy).toPromise()) + } + + async selectVersion (version: Version) { + this._activeVersion = version + this.activeVersion$.next(version) + } + + async selectConfig (config: Config) { + let matchingVersion = this.versions.find(x => x.version === config.last_used_with_version) + if (!matchingVersion) { + // TODO ask to upgrade + matchingVersion = this.versions[0] + } + + this._activeConfig = config + this.activeConfig$.next(config) + this.selectVersion(matchingVersion) + } + + async selectDefaultConfig () { + await this.ready$.toPromise() + await this.loginService.ready$.toPromise() + this.selectConfig(this.configs.find(c => c.id === this.loginService.user.active_config) ?? this.configs[0]) + } + + private async init () { + this.configs = await this.http.get('/api/1/configs').toPromise() + this.versions = await this.http.get('/api/1/versions').toPromise() + this.versions.sort((a, b) => semverGT(a, b)) + + if (!this.configs.length) { + await this.createNewConfig() + } + + this.ready$.next() + this.ready$.complete() + } +} diff --git a/src/services/login.service.ts b/src/services/login.service.ts index 8507800..717d8f7 100644 --- a/src/services/login.service.ts +++ b/src/services/login.service.ts @@ -1,20 +1,29 @@ import { AsyncSubject } from 'rxjs' import { HttpClient } from '@angular/common/http' import { Injectable } from '@angular/core' +import { User } from '../api' @Injectable({ providedIn: 'root' }) export class LoginService { - user: any + user: User ready$ = new AsyncSubject() constructor (private http: HttpClient) { this.init() } + async updateUser () { + await this.http.put('/api/1/user', this.user).toPromise() + } + private async init () { - const user = await this.http.get('/api/1/user').toPromise() - this.user = user + try { + this.user = await this.http.get('/api/1/user').toPromise() + } catch { + this.user = null + } + this.ready$.next() this.ready$.complete() } diff --git a/src/terminal.ts b/src/terminal.ts index e4febff..3c24902 100644 --- a/src/terminal.ts +++ b/src/terminal.ts @@ -1,36 +1,6 @@ import './terminal-styles.scss' - -Object.assign(window, { - // Buffer, - process: { - env: { }, - argv: ['terminus'], - platform: 'darwin', - on: () => null, - stdout: {}, - stderr: {}, - resourcesPath: 'resources', - version: '14.0.0', - nextTick: (f, ...args) => setTimeout(() => f(...args)), - // cwd: () => '/', - }, - global: window, -}) - - async function start () { - const modules = { } - - Object.assign(window, { - require: (path) => { - if (modules[path]) { - return modules[path] - } - console.error('requiring real module', path) - }, - }) - await new Promise(resolve => { window.addEventListener('message', event => { if (event.data === 'connector-ready') { @@ -40,10 +10,11 @@ async function start () { window.parent.postMessage('request-connector', '*') }) - const appVersion = window['__connector__'].getAppVersion() + const connector = window['__connector__'] - async function loadPlugin (name, file = 'index.js') { - const url = `../app-dist/${appVersion}/${name}/dist/${file}` + const appVersion = connector.getAppVersion() + + async function webRequire (url) { console.log(`Loading ${url}`) const e = document.createElement('script') window['module'] = { exports: {} } as any @@ -56,8 +27,11 @@ async function start () { return window['module'].exports } - await loadPlugin('web', 'preload.js') - await loadPlugin('web', 'bundle.js') + const baseUrl = `../app-dist/${appVersion}` + await webRequire(`${baseUrl}/web/dist/preload.js`) + await webRequire(`${baseUrl}/web/dist/bundle.js`) + + const terminus = window['Terminus'] const pluginModules = [] for (const plugin of [ @@ -68,15 +42,13 @@ async function start () { 'terminus-community-color-schemes', 'terminus-web', ]) { - const mod = await loadPlugin(plugin) - modules[`resources/builtin-plugins/${plugin}`] = modules[plugin] = mod - pluginModules.push(mod) + pluginModules.push(await terminus.loadPlugin(`${baseUrl}/${plugin}`)) } document.querySelector('app-root')['style'].display = 'flex' - const config = window['__connector__'].loadConfig() - window['bootstrapTerminus'](pluginModules, { + const config = connector.loadConfig() + terminus.bootstrap(pluginModules, { config, executable: 'web', isFirstWindow: true, diff --git a/src/theme/index.scss b/src/theme/index.scss index 94c4707..f6f9664 100644 --- a/src/theme/index.scss +++ b/src/theme/index.scss @@ -29,7 +29,7 @@ @import "~bootstrap/scss/list-group"; // @import "~bootstrap/scss/close"; // @import "~bootstrap/scss/toasts"; -// @import "~bootstrap/scss/modal"; +@import "~bootstrap/scss/modal"; // @import "~bootstrap/scss/tooltip"; // @import "~bootstrap/scss/popover"; // @import "~bootstrap/scss/carousel"; @@ -62,3 +62,7 @@ .dropdown-menu { box-shadow: $dropdown-box-shadow; } + +.modal-footer { + background: #00000030; +} diff --git a/src/theme/vars.scss b/src/theme/vars.scss index 013a08a..c306aba 100644 --- a/src/theme/vars.scss +++ b/src/theme/vars.scss @@ -77,7 +77,7 @@ $link-hover-color: $white; $link-hover-decoration: none; $component-active-color: $white; -$component-active-bg: #2f3a42; +$component-active-bg: $blue; $list-group-bg: $table-bg; $list-group-border-color: $table-border-color; @@ -113,7 +113,7 @@ $popover-max-width: 360px; $btn-border-width: 2px; -$input-bg: #181e23; +$input-bg: $black; $input-disabled-bg: #2e3235; $input-color: #ddd; @@ -129,6 +129,8 @@ $input-group-addon-bg: $input-bg; $input-group-addon-border-color: transparent; $input-group-btn-border-color: $input-bg; +$form-switch-color: rgba(255,255,255, .25); + $nav-tabs-border-radius: 0; $nav-tabs-border-color: transparent; $nav-tabs-border-width: 2px; @@ -179,8 +181,7 @@ $custom-control-indicator-active-bg: rgba(255, 255, 0, 0.5); $modal-content-bg: $body-bg; $modal-content-border-color: $body-bg; $modal-header-border-width: 0; -$modal-footer-border-color: #222; -$modal-footer-border-width: 1px; +$modal-footer-border-width: 0; $modal-content-border-width: 0; $progress-bg: $table-bg; diff --git a/terminus/app/api.py b/terminus/app/api.py index 988826d..647d5ca 100644 --- a/terminus/app/api.py +++ b/terminus/app/api.py @@ -3,12 +3,13 @@ from dataclasses import dataclass from django.conf import settings from django.contrib.auth import logout from rest_framework import fields +from rest_framework.exceptions import PermissionDenied from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from rest_framework.mixins import ListModelMixin, RetrieveModelMixin +from rest_framework.mixins import ListModelMixin, RetrieveModelMixin, UpdateModelMixin from rest_framework.views import APIView from rest_framework.viewsets import GenericViewSet, ModelViewSet -from rest_framework.serializers import ModelSerializer, Field +from rest_framework.serializers import ModelSerializer from rest_framework_dataclasses.serializers import DataclassSerializer from .models import Config, User @@ -64,21 +65,25 @@ class AppVersionViewSet(ListModelMixin, GenericViewSet): class UserSerializer(ModelSerializer): id = fields.IntegerField() + is_pro = fields.SerializerMethodField() class Meta: model = User - fields = ('id', 'username', 'active_config', 'custom_connection_gateway', 'custom_connection_gateway_token') + fields = ('id', 'username', 'active_config', 'custom_connection_gateway', 'custom_connection_gateway_token', 'is_pro') read_only_fields = ('id', 'username') + def get_is_pro(self, obj): + return False -class UserViewSet(RetrieveModelMixin, GenericViewSet): + +class UserViewSet(RetrieveModelMixin, UpdateModelMixin, GenericViewSet): queryset = User.objects.all() serializer_class = UserSerializer def get_object(self): if self.request.user.is_authenticated: return self.request.user - return None + raise PermissionDenied() class LogoutView(APIView): diff --git a/terminus/app/sponsors.py b/terminus/app/sponsors.py new file mode 100644 index 0000000..ebc5bdb --- /dev/null +++ b/terminus/app/sponsors.py @@ -0,0 +1,63 @@ +from django.conf import settings +from gql import Client, gql +from gql.transport.requests import RequestsHTTPTransport + + +GQL_ENDPOINT = 'https://api.github.com/graphql' + + +def get_sponsor_usernames(): + client = Client( + transport=RequestsHTTPTransport( + url=GQL_ENDPOINT, + use_json=True, + headers={ + 'Authorization': f'Bearer {settings.GITHUB_TOKEN}', + } + ) + ) + + result = [] + + after = None + + while True: + params = 'first: 1' + if after: + params += f', after:"{after}"' + + query = ''' + query { + user (login: "eugeny") { + sponsorshipsAsMaintainer(%s, includePrivate: true) { + pageInfo { + startCursor + hasNextPage + endCursor + } + nodes { + createdAt + tier { + monthlyPriceInDollars + } + sponsor{ + ... on User { + login + } + } + } + } + } + } + ''' % (params,) + + response = client.execute(gql(query)) + after = response['user']['sponsorshipsAsMaintainer']['pageInfo']['endCursor'] + nodes = response['user']['sponsorshipsAsMaintainer']['nodes'] + if not len(nodes): + break + for node in nodes: + if node['tier']['monthlyPriceInDollars'] >= settings.GITHUB_SPONSORS_MIN_PAYMENT: + result.append(node['sponsor']['login']) + + return result diff --git a/terminus/app/urls.py b/terminus/app/urls.py index 40a6e3e..80adb38 100644 --- a/terminus/app/urls.py +++ b/terminus/app/urls.py @@ -12,7 +12,7 @@ router.register('api/1/versions', api.AppVersionViewSet, basename='app-versions' urlpatterns = [ path('api/1/auth/logout', api.LogoutView.as_view()), - path('api/1/user', api.UserViewSet.as_view({'get': 'retrieve'})), + path('api/1/user', api.UserViewSet.as_view({'get': 'retrieve', 'put': 'update'})), path('', views.IndexView.as_view()), path('terminal', views.TerminalView.as_view()), diff --git a/terminus/settings.py b/terminus/settings.py index 101a310..8ffc368 100644 --- a/terminus/settings.py +++ b/terminus/settings.py @@ -136,6 +136,8 @@ AUTHENTICATION_BACKENDS = ( 'django.contrib.auth.backends.ModelBackend', ) +SOCIAL_AUTH_GITHUB_SCOPE = ['read:user', 'user:email'] + LOGIN_REDIRECT_URL = '/' APP_DIST_PATH = BASE_DIR / 'app-dist' @@ -152,9 +154,19 @@ for key in [ 'CONNECTION_GATEWAY_AUTH_CA', 'CONNECTION_GATEWAY_AUTH_CERTIFICATE', 'CONNECTION_GATEWAY_AUTH_KEY', + 'GITHUB_SPONSORS_USER', + 'GITHUB_SPONSORS_MIN_PAYMENT', + 'GITHUB_TOKEN', ]: globals()[key] = os.getenv(key) + +for key in [ + 'GITHUB_SPONSORS_MIN_PAYMENT', +]: + globals()[key] = int(globals()[key]) if globals()[key] else None + + for key in [ 'CONNECTION_GATEWAY_AUTH_CA', 'CONNECTION_GATEWAY_AUTH_CERTIFICATE', diff --git a/webpack.config.js b/webpack.config.js index ca4fed1..45df1d9 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,4 +1,69 @@ -module.exports = [ - require('./webpack.main.config'), - require('./webpack.terminal.config'), -] +const path = require('path') +const webpack = require('webpack') +const { AngularWebpackPlugin } = require('@ngtools/webpack') + +module.exports = { + target: 'web', + entry: { + 'index.ignore': 'file-loader?name=index.html!pug-html-loader!' + path.resolve(__dirname, './src/index.pug'), + index: path.resolve(__dirname, 'src/index.ts'), + 'terminal.ignore': 'file-loader?name=terminal.html!pug-html-loader!' + path.resolve(__dirname, './src/terminal.pug'), + terminal: path.resolve(__dirname, 'src/terminal.ts'), + }, + mode: process.env.DEV ? 'development' : 'production', + context: __dirname, + devtool: 'cheap-module-source-map', + output: { + path: path.join(__dirname, 'build'), + pathinfo: true, + filename: '[name].js', + chunkFilename: '[name].bundle.js', + }, + resolve: { + modules: [ + 'src/', + 'node_modules/', + ], + extensions: ['.ts', '.js'], + }, + module: { + rules: [ + { + test: /\.[jt]sx?$/, + loader: '@ngtools/webpack', + }, + { test: /terminus\/app\/dist/, use: ['script-loader'] }, + { + test: /\.pug$/, + use: ['apply-loader', 'pug-loader'], + include: /component\.pug/ + }, + { + test: /\.scss$/, + use: ['@terminus-term/to-string-loader', 'css-loader', 'sass-loader'], + include: /component\.scss/ + }, + { + test: /\.scss$/, + use: ['style-loader', 'css-loader', 'sass-loader'], + exclude: /component\.scss/ + }, + { + test: /\.(ttf|eot|otf|woff|woff2)(\?v=[0-9]\.[0-9]\.[0-9])?$/, + type: 'asset/resource', + }, + { test: /\.css$/, use: ['css-loader', 'sass-loader'] }, + { + test: /\.(jpeg|png|svg)?$/, + type: 'asset/resource', + } + ], + }, + + plugins: [ + new AngularWebpackPlugin({ + tsconfig: 'tsconfig.main.json', + directTemplateLoading: false, + }), + ], +} diff --git a/webpack.main.config.js b/webpack.main.config.js deleted file mode 100644 index 227b09e..0000000 --- a/webpack.main.config.js +++ /dev/null @@ -1,67 +0,0 @@ -const path = require('path') -const webpack = require('webpack') -const { AngularWebpackPlugin } = require('@ngtools/webpack') - -module.exports = { - target: 'web', - entry: { - 'index.ignore': 'file-loader?name=index.html!pug-html-loader!' + path.resolve(__dirname, './src/index.pug'), - index: path.resolve(__dirname, 'src/index.ts'), - }, - mode: process.env.DEV ? 'development' : 'production', - context: __dirname, - devtool: 'cheap-module-source-map', - output: { - path: path.join(__dirname, 'build'), - pathinfo: true, - filename: '[name].js', - chunkFilename: '[name].bundle.js', - }, - resolve: { - modules: [ - 'src/', - 'node_modules/', - ], - extensions: ['.ts', '.js'], - }, - module: { - rules: [ - { - test: /\.[jt]sx?$/, - loader: '@ngtools/webpack', - }, - { test: /terminus\/app\/dist/, use: ['script-loader'] }, - { - test: /\.pug$/, - use: ['apply-loader', 'pug-loader'], - include: /component\.pug/ - }, - { - test: /\.scss$/, - use: ['@terminus-term/to-string-loader', 'css-loader', 'sass-loader'], - include: /component\.scss/ - }, - { - test: /\.scss$/, - use: ['style-loader', 'css-loader', 'sass-loader'], - exclude: /component\.scss/ - }, - { - test: /\.(ttf|eot|otf|woff|woff2)(\?v=[0-9]\.[0-9]\.[0-9])?$/, - type: 'asset/resource', - }, - { test: /\.css$/, use: ['css-loader', 'sass-loader'] }, - { - test: /\.(jpeg|png|svg)?$/, - type: 'asset/resource', - } - ], - }, - - plugins: [ - new AngularWebpackPlugin({ - tsconfig: 'tsconfig.main.json', - directTemplateLoading: false, - }), - ], -} diff --git a/webpack.terminal.config.js b/webpack.terminal.config.js deleted file mode 100644 index 9bd6dbd..0000000 --- a/webpack.terminal.config.js +++ /dev/null @@ -1,72 +0,0 @@ -const path = require('path') -const webpack = require('webpack') - -module.exports = { - name: 'web-container-terminal', - target: 'web', - entry: { - 'terminal.ignore': 'file-loader?name=terminal.html!pug-html-loader!' + path.resolve(__dirname, './src/terminal.pug'), - terminal: path.resolve(__dirname, 'src/terminal.ts'), - }, - mode: process.env.DEV ? 'development' : 'production', - context: __dirname, - devtool: 'cheap-module-source-map', - output: { - path: path.join(__dirname, 'build'), - pathinfo: true, - filename: '[name].js', - chunkFilename: '[name].bundle.js', - }, - resolve: { - modules: [ - ...[ - // '../terminus/terminus-core/node_modules/', - // '../terminus/terminus-settings/node_modules/', - // '../terminus/terminus-terminal/node_modules/', - // '../terminus/node_modules', - // '../terminus/app/node_modules', - // '../terminus/app/assets/', - 'src', - ].map(x => path.join(__dirname, x)), - 'node_modules/', - ], - extensions: ['.ts', '.js'], - }, - module: { - rules: [ - { - test: /\.ts$/, - use: { - loader: 'awesome-typescript-loader', - options: { - configFileName: path.resolve(__dirname, 'tsconfig.container.json'), - }, - }, - }, - // { test: /terminus\/app\/dist/, use: ['script-loader'] }, - { - test: /\.scss$/, - use: ['style-loader', 'css-loader', 'sass-loader'], - }, - { - test: /\.(ttf|eot|otf|woff|woff2)(\?v=[0-9]\.[0-9]\.[0-9])?$/, - use: { - loader: 'file-loader', - options: { - name: 'fonts/[name].[ext]', - }, - }, - }, - { test: /\.css$/, use: ['css-loader', 'sass-loader'] }, - { - test: /\.(jpeg|png|svg)?$/, - use: { - loader: 'file-loader', - options: { - name: '[name].[ext]' - } - } - } - ], - } -}