initial commit
This commit is contained in:
41
projects/mastolists/src/app/app-routing.module.ts
Normal file
41
projects/mastolists/src/app/app-routing.module.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import {NgModule} from '@angular/core';
|
||||
import {RouterModule, Routes} from '@angular/router';
|
||||
import {AuthGuard} from './shared/guards/auth.guard';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
redirectTo: 'home',
|
||||
pathMatch: 'full'
|
||||
},
|
||||
{
|
||||
path: 'home',
|
||||
loadChildren: () => import('./home/home.module').then(m => m.HomeModule),
|
||||
},
|
||||
{
|
||||
path: 'auth',
|
||||
loadChildren: () => import('./authorization/authorization.module').then(m => m.AuthorizationModule),
|
||||
},
|
||||
{
|
||||
path: 'sync',
|
||||
loadChildren: () => import('./sync/sync.module').then(m => m.SyncModule),
|
||||
canActivate: [AuthGuard],
|
||||
},
|
||||
{
|
||||
path: 'followings',
|
||||
loadChildren: () => import('./followings/followings.module').then(m => m.FollowingsModule),
|
||||
canActivate: [AuthGuard],
|
||||
},
|
||||
{
|
||||
path: 'matrix',
|
||||
loadChildren: () => import('./followings-matrix/followings-matrix.module').then(m => m.FollowingsMatrixModule),
|
||||
canActivate: [AuthGuard],
|
||||
},
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forRoot(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class AppRoutingModule {
|
||||
}
|
||||
42
projects/mastolists/src/app/app.component.html
Normal file
42
projects/mastolists/src/app/app.component.html
Normal file
@@ -0,0 +1,42 @@
|
||||
<ng-progress [spinner]="false" [debounceTime]="300" [min]="20" [color]="'#83ebd6'" [thick]="true"></ng-progress>
|
||||
<nb-layout center>
|
||||
<nb-layout-header subheader>
|
||||
<div class="navbar-container">
|
||||
<div id="logo" style="padding-right: 20px;">
|
||||
<a [routerLink]="['/']">
|
||||
<img height="40px" src="assets/images/novaloop.png" alt="Novaloop favicon">
|
||||
</a>
|
||||
</div>
|
||||
<div><h2>Mastolists</h2></div>
|
||||
<!--add class button-responsive to the button-->
|
||||
<button nbButton ghost class="button-responsive" [nbContextMenu]="navigationItems">
|
||||
<nb-icon icon="menu-outline"></nb-icon>
|
||||
</button>
|
||||
<!--add class menu-responsive to nb-actions-->
|
||||
<nb-actions class="left menu-responsive">
|
||||
<nb-action *ngFor="let item of navigationItems"
|
||||
[routerLink]="item.link"
|
||||
[title]="item.title">
|
||||
{{ item.title }}
|
||||
</nb-action>
|
||||
</nb-actions>
|
||||
</div>
|
||||
</nb-layout-header>
|
||||
|
||||
<nb-layout-column>
|
||||
<router-outlet></router-outlet>
|
||||
</nb-layout-column>
|
||||
|
||||
<nb-layout-footer>
|
||||
<div style="width: 50%">
|
||||
Novaloop AG<br/>
|
||||
Niederdorfstrasse 88<br/>
|
||||
8001 Zürich<br/>
|
||||
</div>
|
||||
<div style="width: 50%; text-align: right;">
|
||||
<a href="tel:+41 44 500 54 60">+41 44 500 54 60</a><br/>
|
||||
<a href="https://www.novaloop.ch">www.novaloop.ch</a><br/>
|
||||
<a href="mailto:mail@novaloop.ch">mail@novaloop.ch</a><br/>
|
||||
</div>
|
||||
</nb-layout-footer>
|
||||
</nb-layout>
|
||||
24
projects/mastolists/src/app/app.component.scss
Normal file
24
projects/mastolists/src/app/app.component.scss
Normal file
@@ -0,0 +1,24 @@
|
||||
.button-responsive {
|
||||
display: none
|
||||
}
|
||||
|
||||
@media (max-width: 573px) {
|
||||
.button-responsive {
|
||||
display: inline-block
|
||||
}
|
||||
.menu-responsive {
|
||||
display: none
|
||||
}
|
||||
}
|
||||
|
||||
nb-action {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.navbar-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
}
|
||||
35
projects/mastolists/src/app/app.component.spec.ts
Normal file
35
projects/mastolists/src/app/app.component.spec.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { AppComponent } from './app.component';
|
||||
|
||||
describe('AppComponent', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
RouterTestingModule
|
||||
],
|
||||
declarations: [
|
||||
AppComponent
|
||||
],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
it('should create the app', () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
const app = fixture.componentInstance;
|
||||
expect(app).toBeTruthy();
|
||||
});
|
||||
|
||||
it(`should have as title 'mastolists'`, () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
const app = fixture.componentInstance;
|
||||
expect(app.title).toEqual('mastolists');
|
||||
});
|
||||
|
||||
it('should render title', () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.querySelector('.content span')?.textContent).toContain('mastolists app is running!');
|
||||
});
|
||||
});
|
||||
29
projects/mastolists/src/app/app.component.ts
Normal file
29
projects/mastolists/src/app/app.component.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import {Component, isDevMode} from '@angular/core';
|
||||
import {select, Store} from "@ngrx/store";
|
||||
import {MastodonApiActions} from "./shared/state/store/actions";
|
||||
import {Observable, tap} from "rxjs";
|
||||
import {selectLoadingPercentage} from "./shared/state/store/selectors";
|
||||
import {PersistentStore} from "./shared/state/persistent/persistent-store.service";
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
templateUrl: './app.component.html',
|
||||
styleUrls: ['./app.component.scss']
|
||||
})
|
||||
export class AppComponent {
|
||||
title = 'mastolists';
|
||||
|
||||
navigationItems = [
|
||||
{title: 'Authorize', link: '/auth'},
|
||||
{title: 'Stats', link: '/sync'},
|
||||
{title: 'List view', link: '/followings'},
|
||||
{title: 'Matrix View', link: '/matrix'},
|
||||
];
|
||||
|
||||
constructor(private store: Store, private persistentStore: PersistentStore) {
|
||||
if (this.persistentStore.isAuthorized() && !isDevMode()) {
|
||||
this.store.dispatch(MastodonApiActions.loadLists());
|
||||
this.store.dispatch(MastodonApiActions.loadFollowings());
|
||||
}
|
||||
}
|
||||
}
|
||||
73
projects/mastolists/src/app/app.module.ts
Normal file
73
projects/mastolists/src/app/app.module.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import {NgModule} from '@angular/core';
|
||||
import {BrowserModule} from '@angular/platform-browser';
|
||||
|
||||
import {AppRoutingModule} from './app-routing.module';
|
||||
import {AppComponent} from './app.component';
|
||||
import {MastodonApiModule} from "../../../mastodon-api/src/lib/mastodon-api.module";
|
||||
import {
|
||||
NbActionsModule,
|
||||
NbContextMenuModule, NbDialogModule,
|
||||
NbGlobalPhysicalPosition,
|
||||
NbLayoutModule,
|
||||
NbMenuModule, NbProgressBarModule,
|
||||
NbThemeModule,
|
||||
NbToastrModule
|
||||
} from "@nebular/theme";
|
||||
import {BrowserAnimationsModule} from "@angular/platform-browser/animations";
|
||||
import {SharedModule} from './shared/shared.module';
|
||||
import {NbEvaIconsModule} from "@nebular/eva-icons";
|
||||
import {StoreModule} from '@ngrx/store';
|
||||
import {StoreDevtoolsModule} from "@ngrx/store-devtools";
|
||||
import {applicationStateReducers} from "./shared/state/store/reducers";
|
||||
import {EffectsModule} from "@ngrx/effects";
|
||||
import {ApplicationEffects} from "./shared/state/store/effects";
|
||||
import {NgProgressModule} from "ngx-progressbar";
|
||||
import {NgProgressHttpModule} from "ngx-progressbar/http";
|
||||
|
||||
const toastrConfig = {
|
||||
duration: 3000,
|
||||
position: NbGlobalPhysicalPosition.BOTTOM_RIGHT,
|
||||
preventDuplicates: true,
|
||||
destroyByClick: true,
|
||||
};
|
||||
|
||||
@NgModule({
|
||||
bootstrap: [AppComponent],
|
||||
declarations: [
|
||||
AppComponent,
|
||||
],
|
||||
imports: [
|
||||
BrowserModule,
|
||||
BrowserAnimationsModule,
|
||||
AppRoutingModule,
|
||||
MastodonApiModule.forRoot(),
|
||||
StoreModule.forRoot({}),
|
||||
StoreModule.forFeature('applicationState', applicationStateReducers),
|
||||
EffectsModule.forRoot(ApplicationEffects),
|
||||
StoreDevtoolsModule.instrument({
|
||||
maxAge: 25,
|
||||
logOnly: false,
|
||||
autoPause: true,
|
||||
features: {
|
||||
pause: false,
|
||||
lock: true,
|
||||
persist: true
|
||||
}
|
||||
}),
|
||||
SharedModule,
|
||||
NgProgressModule,
|
||||
NgProgressHttpModule,
|
||||
// Nebula
|
||||
NbContextMenuModule,
|
||||
NbMenuModule.forRoot(),
|
||||
NbDialogModule.forRoot(),
|
||||
NbEvaIconsModule,
|
||||
NbActionsModule,
|
||||
NbThemeModule.forRoot({name: 'dark'}),
|
||||
NbToastrModule.forRoot(toastrConfig),
|
||||
NbLayoutModule,
|
||||
],
|
||||
providers: []
|
||||
})
|
||||
export class AppModule {
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import {RouterModule, Routes} from "@angular/router";
|
||||
import {NgModule} from "@angular/core";
|
||||
import {AuthorizeComponent} from "./authorize/authorize.component";
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: AuthorizeComponent
|
||||
}
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class AuthorizationRoutingModule {
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import {NgModule} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {AuthorizationRoutingModule} from "./authorization-routing.module";
|
||||
import {AuthorizeComponent} from './authorize/authorize.component';
|
||||
import {ReactiveFormsModule} from '@angular/forms';
|
||||
import {SharedModule} from "../shared/shared.module";
|
||||
import {NbSpinnerModule} from "@nebular/theme";
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
AuthorizeComponent
|
||||
],
|
||||
imports: [
|
||||
AuthorizationRoutingModule,
|
||||
CommonModule,
|
||||
SharedModule,
|
||||
ReactiveFormsModule,
|
||||
// Nebula
|
||||
NbSpinnerModule,
|
||||
]
|
||||
})
|
||||
export class AuthorizationModule {
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<nb-card>
|
||||
<nb-card-header>
|
||||
<h1>Authorize with your instance</h1>
|
||||
</nb-card-header>
|
||||
<nb-card-body>
|
||||
<div [formGroup]="serverForm">
|
||||
<input nbInput id="serverUrl" type="text" placeholder="mastodon.social" [formControlName]="'instance'">
|
||||
<button class="authorize-button" nbButton type="button"
|
||||
[nbSpinner]="authorizing"
|
||||
(click)="registerApplication()">Authorize
|
||||
</button>
|
||||
</div>
|
||||
<div *ngIf="isAuthorized.value">
|
||||
<p>User is authorized with {{currentInstance?.instanceName}}</p>
|
||||
<p>Next up: <a routerLink="/sync">Sync</a></p>
|
||||
</div>
|
||||
</nb-card-body>
|
||||
</nb-card>
|
||||
@@ -0,0 +1,9 @@
|
||||
.authorize-button {
|
||||
margin: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 573px) {
|
||||
.authorize-button {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { AuthorizeComponent } from './authorize.component';
|
||||
|
||||
describe('AuthorizeComponent', () => {
|
||||
let component: AuthorizeComponent;
|
||||
let fixture: ComponentFixture<AuthorizeComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [ AuthorizeComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(AuthorizeComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import {Component, OnDestroy, OnInit} from '@angular/core';
|
||||
import {FormControl, FormGroup} from "@angular/forms";
|
||||
import {ActivatedRoute, Params, Router} from "@angular/router";
|
||||
import {BehaviorSubject, filter, Subscription, take} from "rxjs";
|
||||
import {PersistentStore} from "../../shared/state/persistent/persistent-store.service";
|
||||
import {MastodonApiAccountsService, MastodonApiAuthenticationService, MastodonApiListsService} from "projects/mastodon-api/src/public-api";
|
||||
import {Instance} from "../../shared/state/persistent/state";
|
||||
|
||||
@Component({
|
||||
selector: 'app-authorize',
|
||||
templateUrl: './authorize.component.html',
|
||||
styleUrls: ['./authorize.component.scss']
|
||||
})
|
||||
export class AuthorizeComponent implements OnInit, OnDestroy {
|
||||
subscriptions: Subscription[] = [];
|
||||
currentInstance?: Instance;
|
||||
accessToken?: string;
|
||||
accountId?: string;
|
||||
isAuthorized: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
|
||||
authorizing: boolean = false;
|
||||
|
||||
serverForm = new FormGroup({
|
||||
instance: new FormControl(''),
|
||||
});
|
||||
|
||||
constructor(private mastodonApiAuthService: MastodonApiAuthenticationService,
|
||||
private store: PersistentStore,
|
||||
private mastodonApiAccountsService: MastodonApiAccountsService,
|
||||
private mastodonApiListsService: MastodonApiListsService,
|
||||
private router: Router,
|
||||
private route: ActivatedRoute,) {
|
||||
this.subscriptions.push(this.store
|
||||
.select('currentInstance')
|
||||
.subscribe((currentInstance) => {
|
||||
this.currentInstance = <Instance>currentInstance
|
||||
this.accessToken = this.currentInstance?.accessToken;
|
||||
this.accountId = this.currentInstance?.accountId;
|
||||
this.isAuthorized.next(!!(this.currentInstance && this.accessToken && this.accountId));
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
ngOnInit(): void {
|
||||
this.route.queryParams
|
||||
.pipe(
|
||||
filter((params: Params) => params['code']),
|
||||
take(1)
|
||||
)
|
||||
.subscribe(params => {
|
||||
if (this.currentInstance && !this.accessToken && params['code']) {
|
||||
this.mastodonApiAuthService
|
||||
.getAccessToken(this.currentInstance.instanceName, this.currentInstance.clientId, this.currentInstance.clientSecret, this.currentInstance.redirectUrl, params['code'])
|
||||
.pipe(take(1))
|
||||
.subscribe(accessToken => {
|
||||
this.store.set(
|
||||
'currentInstance',
|
||||
{...this.currentInstance, 'accessToken': accessToken}
|
||||
);
|
||||
this.mastodonApiAuthService.verifyCredentials(this.currentInstance!.instanceName, accessToken)
|
||||
.pipe(take(1))
|
||||
.subscribe((accountId) => {
|
||||
this.store.set(
|
||||
'currentInstance',
|
||||
{...this.currentInstance, 'accountId': accountId}
|
||||
);
|
||||
this.router.navigate(['/sync']);
|
||||
this.isAuthorized.next(true);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async registerApplication() {
|
||||
this.authorizing = true;
|
||||
this.subscriptions.push(this.store
|
||||
.select<Instance>('currentInstance')
|
||||
.subscribe((currentInstance) => {
|
||||
this.authorizing = false;
|
||||
let instanceName = this.serverForm.value.instance ?? '';
|
||||
if (instanceName === '') {
|
||||
return;
|
||||
}
|
||||
if (currentInstance?.instanceName === instanceName) {
|
||||
this.mastodonApiAuthService
|
||||
.authorizeUser(currentInstance.instanceName, currentInstance.clientId, currentInstance.redirectUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
// let redirectUrl = window.location.toString();
|
||||
let redirectUrl = window.location.protocol + '//' + window.location.host + window.location.pathname;
|
||||
this.authorizing = true;
|
||||
this.mastodonApiAuthService
|
||||
.createApp(instanceName, 'Mastolists', redirectUrl, 'https://mastolists.novaloop.cloud')
|
||||
.pipe(take(1))
|
||||
.subscribe(res => {
|
||||
this.authorizing = false;
|
||||
this.store.set(
|
||||
'currentInstance',
|
||||
<Instance>{
|
||||
id: res.id,
|
||||
instanceName: instanceName,
|
||||
appName: res.name,
|
||||
website: res.website,
|
||||
redirectUrl: res.redirectUri,
|
||||
clientId: res.clientId,
|
||||
clientSecret: res.clientSecret,
|
||||
}
|
||||
);
|
||||
this.mastodonApiAuthService.authorizeUser(instanceName, res.clientId, res.redirectUri);
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.subscriptions.forEach(subscription => subscription.unsubscribe());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import {RouterModule, Routes} from "@angular/router";
|
||||
import {NgModule} from "@angular/core";
|
||||
import {MatrixComponent} from "./matrix/matrix.component";
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: MatrixComponent,
|
||||
}
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class FollowingsMatrixRoutingModule {
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import {NgModule} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {MatrixComponent} from './matrix/matrix.component';
|
||||
import {FollowingsMatrixRoutingModule} from './followings-matrix-routing.module';
|
||||
import {SharedModule} from "../shared/shared.module";
|
||||
import {NbBadgeModule, NbCheckboxModule, NbPopoverModule} from "@nebular/theme";
|
||||
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
MatrixComponent
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
SharedModule,
|
||||
FollowingsMatrixRoutingModule,
|
||||
// Nebular
|
||||
NbCheckboxModule,
|
||||
NbBadgeModule,
|
||||
NbPopoverModule,
|
||||
]
|
||||
|
||||
})
|
||||
export class FollowingsMatrixModule {
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<nb-card>
|
||||
<nb-card-header style="position: relative;">
|
||||
<h1>Matrix View for Followings</h1>
|
||||
<div class="divider"></div>
|
||||
<nb-badge text="experimental" position="top right" status="success"></nb-badge>
|
||||
<app-filters></app-filters>
|
||||
</nb-card-header>
|
||||
<nb-card-body>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th *ngFor="let list of lists$ | async" class="list-title">
|
||||
{{list.title}}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let account of followings$ | async">
|
||||
<th>
|
||||
<ng-template #templateRef>
|
||||
<div style="padding: 20px;">
|
||||
<p [innerHTML]="account.note"></p>
|
||||
<h4 *ngIf="account.fields.length > 0">Custom fields</h4>
|
||||
<div class="table">
|
||||
<div class="row" *ngFor="let field of account.fields">
|
||||
<div class="column first">{{ field.name }}</div>
|
||||
<div class="column" [innerHTML]="field.value"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
<span (click)="openMoreInfo(account)"
|
||||
[nbPopover]="templateRef"
|
||||
nbPopoverTrigger="hover"
|
||||
nbPopoverPlacement="bottom"
|
||||
>
|
||||
@{{account.username}}
|
||||
</span>
|
||||
</th>
|
||||
<td *ngFor="let list of lists$ | async">
|
||||
<nb-checkbox
|
||||
[checked]="isChecked(account.lists, list.id)"
|
||||
(checkedChange)="onCheckedChange($event, account.id, list.id)">
|
||||
</nb-checkbox>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</nb-card-body>
|
||||
</nb-card>
|
||||
|
||||
<ng-template #dialog let-ref="dialogRef">
|
||||
<div class="container" *ngIf="selectedAccount">
|
||||
<app-account [account]="selectedAccount">
|
||||
<div footer>
|
||||
<button nbButton style="cursor: pointer;" (click)="ref.close()">Close Infos</button>
|
||||
</div>
|
||||
</app-account>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
table {
|
||||
display: block;
|
||||
height: max(600px, calc(100vh - 300px));
|
||||
width: 1080px;
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
border: none;
|
||||
|
||||
td {
|
||||
border: none;
|
||||
}
|
||||
|
||||
thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background-color: #222b45;
|
||||
z-index: 2;
|
||||
border: none;
|
||||
}
|
||||
|
||||
thead th.list-title {
|
||||
writing-mode: vertical-lr;
|
||||
transform: rotate(180deg);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
tbody th {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
background-color: #222b45;
|
||||
border: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
line-height: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
nb-card.more-info-panel {
|
||||
min-width: 450px;
|
||||
max-width: 800px;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { MatrixComponent } from './matrix.component';
|
||||
|
||||
describe('MatrixComponent', () => {
|
||||
let component: MatrixComponent;
|
||||
let fixture: ComponentFixture<MatrixComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [ MatrixComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(MatrixComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import {Component, TemplateRef, ViewChild} from '@angular/core';
|
||||
import {PersistentStore} from "../../shared/state/persistent/persistent-store.service";
|
||||
import {Account} from "../../../../../mastodon-api/src/lib/interfaces/public/account";
|
||||
import {List} from "../../../../../mastodon-api/src/lib/interfaces/public/list";
|
||||
import {ListService} from "../../shared/services/list.service";
|
||||
import {Observable} from "rxjs";
|
||||
import {select, Store} from "@ngrx/store";
|
||||
import {selectFilteredFollowingsWithLists, selectLists} from "../../shared/state/store/selectors";
|
||||
import {ListActions} from "../../shared/state/store/actions";
|
||||
import {NbDialogService} from "@nebular/theme";
|
||||
|
||||
@Component({
|
||||
selector: 'app-matrix',
|
||||
templateUrl: './matrix.component.html',
|
||||
styleUrls: ['./matrix.component.scss']
|
||||
})
|
||||
export class MatrixComponent {
|
||||
@ViewChild('dialog', {static: true}) dialog: TemplateRef<any> | undefined;
|
||||
followings$: Observable<ReadonlyArray<Account>>;
|
||||
lists$: Observable<ReadonlyArray<List>>;
|
||||
selectedAccount: Account | undefined;
|
||||
|
||||
constructor(private store: Store, private dialogService: NbDialogService) {
|
||||
this.followings$ = this.store.pipe(select(selectFilteredFollowingsWithLists));
|
||||
this.lists$ = this.store.pipe(select(selectLists));
|
||||
}
|
||||
|
||||
openMoreInfo(account: Account) {
|
||||
this.selectedAccount = account;
|
||||
this.dialogService.open(this.dialog!);
|
||||
}
|
||||
|
||||
isChecked(lists: List[], id: string): boolean {
|
||||
return lists.some(list => list.id === id);
|
||||
}
|
||||
|
||||
onCheckedChange($event: boolean, accountId: string, listId: string) {
|
||||
if ($event) {
|
||||
this.store.dispatch(ListActions.addAccountToList({accountId, listId}));
|
||||
} else {
|
||||
this.store.dispatch(ListActions.removeAccountFromList({accountId, listId}));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import {RouterModule, Routes} from "@angular/router";
|
||||
import {NgModule} from "@angular/core";
|
||||
import {FollowingsComponent} from "./followings/followings.component";
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: FollowingsComponent
|
||||
}
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class FollowingsRoutingModule {
|
||||
}
|
||||
23
projects/mastolists/src/app/followings/followings.module.ts
Normal file
23
projects/mastolists/src/app/followings/followings.module.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import {NgModule} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {FollowingsComponent} from './followings/followings.component';
|
||||
import {SharedModule} from '../shared/shared.module';
|
||||
import {FollowingsRoutingModule} from './followings-routing.module';
|
||||
import {NbListModule, NbToggleModule} from "@nebular/theme";
|
||||
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
FollowingsComponent
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
SharedModule,
|
||||
FollowingsRoutingModule,
|
||||
// Nebula
|
||||
NbListModule,
|
||||
NbToggleModule,
|
||||
]
|
||||
})
|
||||
export class FollowingsModule {
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<nb-card>
|
||||
<nb-card-header>
|
||||
<h1>Followings</h1>
|
||||
<div class="divider"></div>
|
||||
<app-filters></app-filters>
|
||||
</nb-card-header>
|
||||
<nb-card-body>
|
||||
<nb-list>
|
||||
<nb-list-item *ngFor="let following of followings$ | async">
|
||||
<app-account [account]="following">
|
||||
<div body>
|
||||
<div *ngIf="following.lists.length > 0">
|
||||
<h4>Lists</h4>
|
||||
<ul>
|
||||
<li *ngFor="let list of following.lists">
|
||||
<a [routerLink]="['/lists']" [queryParams]="{listId: list.id}">{{list.title}}</a>
|
||||
<nb-icon icon="trash-2-outline" class="nb-icon-remove"
|
||||
(click)="removeAccountFromList(following.id, list.id)"></nb-icon>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div footer>
|
||||
<select class="list-select" #listSelect>
|
||||
<option *ngFor="let list of lists$ | async" [value]="list.id">{{list.title}}</option>
|
||||
</select>
|
||||
<button class="add-to-list-button" nbButton
|
||||
status="basic"
|
||||
(click)="addAccountToSelectedList(following.id, listSelect)">Add to list
|
||||
</button>
|
||||
</div>
|
||||
</app-account>
|
||||
</nb-list-item>
|
||||
</nb-list>
|
||||
</nb-card-body>
|
||||
</nb-card>
|
||||
@@ -0,0 +1,22 @@
|
||||
select.list-select {
|
||||
height: 40px;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
nb-icon.nb-icon-remove {
|
||||
margin-left: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.add-to-list-button {
|
||||
margin-left: 20px;
|
||||
margin-top: -3px;
|
||||
}
|
||||
|
||||
@media (max-width: 573px) {
|
||||
.add-to-list-button {
|
||||
margin-top: 20px;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { FollowingsComponent } from './followings.component';
|
||||
|
||||
describe('FollowingsComponent', () => {
|
||||
let component: FollowingsComponent;
|
||||
let fixture: ComponentFixture<FollowingsComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [ FollowingsComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(FollowingsComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import {Component} from '@angular/core';
|
||||
import {PersistentStore} from "../../shared/state/persistent/persistent-store.service";
|
||||
import {Account, List, MastodonApiListsService} from 'projects/mastodon-api/src/public-api';
|
||||
import {NbToastrService} from "@nebular/theme";
|
||||
import {ListService} from "../../shared/services/list.service";
|
||||
import {Observable} from "rxjs";
|
||||
import {select, Store} from "@ngrx/store";
|
||||
import {selectFilteredFollowingsWithLists, selectFollowings, selectFollowingsWithLists, selectLists} from "../../shared/state/store/selectors";
|
||||
import {FiltersActions, ListActions, MastodonApiActions} from "../../shared/state/store/actions";
|
||||
|
||||
@Component({
|
||||
selector: 'app-followings',
|
||||
templateUrl: './followings.component.html',
|
||||
styleUrls: ['./followings.component.scss']
|
||||
})
|
||||
export class FollowingsComponent {
|
||||
followings$: Observable<ReadonlyArray<Account>>;
|
||||
lists$: Observable<ReadonlyArray<List>>;
|
||||
|
||||
constructor(private store: Store) {
|
||||
this.followings$ = this.store.pipe(select(selectFilteredFollowingsWithLists));
|
||||
this.lists$ = this.store.pipe(select(selectLists));
|
||||
}
|
||||
|
||||
addAccountToSelectedList(accountId: string, select: HTMLSelectElement) {
|
||||
this.store.dispatch(ListActions.addAccountToList({accountId, listId: select.value}));
|
||||
}
|
||||
|
||||
removeAccountFromList(accountId: string, listId: string) {
|
||||
this.store.dispatch(ListActions.removeAccountFromList({accountId, listId}));
|
||||
}
|
||||
|
||||
}
|
||||
17
projects/mastolists/src/app/home/home-routing.module.ts
Normal file
17
projects/mastolists/src/app/home/home-routing.module.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import {RouterModule, Routes} from "@angular/router";
|
||||
import {NgModule} from "@angular/core";
|
||||
import {HomeComponent} from "./home/home.component";
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: HomeComponent
|
||||
}
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class HomeRoutingModule {
|
||||
}
|
||||
19
projects/mastolists/src/app/home/home.module.ts
Normal file
19
projects/mastolists/src/app/home/home.module.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import {NgModule} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {HomeComponent} from "./home/home.component";
|
||||
import {SharedModule} from "../shared/shared.module";
|
||||
import {HomeRoutingModule} from "./home-routing.module";
|
||||
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
HomeComponent,
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
SharedModule,
|
||||
HomeRoutingModule,
|
||||
]
|
||||
})
|
||||
export class HomeModule {
|
||||
}
|
||||
15
projects/mastolists/src/app/home/home/home.component.html
Normal file
15
projects/mastolists/src/app/home/home/home.component.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<nb-card>
|
||||
<nb-card-header>
|
||||
<h1>Welcome to Mastolists</h1>
|
||||
</nb-card-header>
|
||||
<nb-card-body>
|
||||
<p>Organize your Followings into lists.</p>
|
||||
|
||||
<ul>
|
||||
<li><a [routerLink]="['/auth']">Authorize</a> with your instance</li>
|
||||
<li><a [routerLink]="['/sync']">Sync</a> your lists and followings to local storage</li>
|
||||
<li>Select the <a [routerLink]="['/followings']">List view</a> to add and remove users from lists</li>
|
||||
<li>... or use the experimental <a [routerLink]="['/matrix']">Matrix view</a></li>
|
||||
</ul>
|
||||
</nb-card-body>
|
||||
</nb-card>
|
||||
23
projects/mastolists/src/app/home/home/home.component.spec.ts
Normal file
23
projects/mastolists/src/app/home/home/home.component.spec.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { HomeComponent } from './home.component';
|
||||
|
||||
describe('HomeComponent', () => {
|
||||
let component: HomeComponent;
|
||||
let fixture: ComponentFixture<HomeComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [ HomeComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(HomeComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
10
projects/mastolists/src/app/home/home/home.component.ts
Normal file
10
projects/mastolists/src/app/home/home/home.component.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-home',
|
||||
templateUrl: './home.component.html',
|
||||
styleUrls: ['./home.component.scss']
|
||||
})
|
||||
export class HomeComponent {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<nb-card accent="info" class="account-card">
|
||||
<nb-card-header>
|
||||
<a [href]="account.url">
|
||||
<nb-user
|
||||
size="medium"
|
||||
shape="semi-round"
|
||||
[name]="account.displayName"
|
||||
[title]="'@' + account.username"
|
||||
[picture]="account.avatar"
|
||||
>
|
||||
</nb-user>
|
||||
</a>
|
||||
<ng-content select="'[header]'"></ng-content>
|
||||
</nb-card-header>
|
||||
<nb-card-body>
|
||||
<p [innerHTML]="account.note"></p>
|
||||
<h4 *ngIf="account.fields.length > 0">Custom fields</h4>
|
||||
<div class="table">
|
||||
<div class="row" *ngFor="let field of account.fields">
|
||||
<div class="column first">{{ field.name }}</div>
|
||||
<div class="column" [innerHTML]="field.value"></div>
|
||||
</div>
|
||||
</div>
|
||||
<ng-content select="'[body]'"></ng-content>
|
||||
</nb-card-body>
|
||||
<nb-card-footer>
|
||||
<ng-content select="'[footer]'"></ng-content>
|
||||
</nb-card-footer>
|
||||
</nb-card>
|
||||
@@ -0,0 +1,29 @@
|
||||
:host {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
td.field-label {
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
min-height: 30px;
|
||||
|
||||
.first {
|
||||
min-width: 160px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 573px) {
|
||||
.row {
|
||||
flex-direction: column;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { AccountComponent } from './account.component';
|
||||
|
||||
describe('AccountComponent', () => {
|
||||
let component: AccountComponent;
|
||||
let fixture: ComponentFixture<AccountComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [ AccountComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(AccountComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import {Component, Input} from '@angular/core';
|
||||
import {Account} from 'projects/mastodon-api/src/public-api';
|
||||
|
||||
@Component({
|
||||
selector: 'app-account',
|
||||
templateUrl: './account.component.html',
|
||||
styleUrls: ['./account.component.scss']
|
||||
})
|
||||
export class AccountComponent {
|
||||
|
||||
@Input() account!: Account;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<div class="container">
|
||||
<div class="form">
|
||||
<form [formGroup]="filtersForm">
|
||||
<input nbInput id="text" type="text"
|
||||
placeholder="@username or free text"
|
||||
[formControlName]="'text'">
|
||||
<nb-select multiple placeholder="Multiple Select" [formControlName]="'lists'">
|
||||
<nb-option *ngFor="let list of lists$ | async" [value]="list.id">{{list.title}}</nb-option>
|
||||
</nb-select>
|
||||
</form>
|
||||
<div class="nb-toggle-container">
|
||||
<nb-toggle
|
||||
[checked]="(filters$ | async)?.unlisted"
|
||||
(checkedChange)="toggleUnlisted($event)"
|
||||
>Unlisted only
|
||||
</nb-toggle>
|
||||
</div>
|
||||
<button nbButton (click)="clearFilter()">Clear Filter</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,54 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
|
||||
input {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
nb-select {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.nb-toggle-container {
|
||||
padding-top: 2px;
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.buttons {
|
||||
padding-top: 20px;
|
||||
|
||||
button {
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 573px) {
|
||||
.form {
|
||||
flex-direction: column;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
input {
|
||||
margin-top: 10px;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
nb-select {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.nb-toggle-container {
|
||||
margin-top: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { FiltersComponent } from './filters.component';
|
||||
|
||||
describe('FiltersComponent', () => {
|
||||
let component: FiltersComponent;
|
||||
let fixture: ComponentFixture<FiltersComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [ FiltersComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(FiltersComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import {Component, OnDestroy} from '@angular/core';
|
||||
import {FormControl, FormGroup} from "@angular/forms";
|
||||
import {select, Store} from "@ngrx/store";
|
||||
import {FiltersActions, MastodonApiActions} from "../../state/store/actions";
|
||||
import {debounceTime, Observable, Subscription, tap} from "rxjs";
|
||||
import {List} from 'projects/mastodon-api/src/public-api';
|
||||
import {selectFilters, selectFiltersForForm, selectLists} from "../../state/store/selectors";
|
||||
import {Filters} from "../../state/store/reducers";
|
||||
|
||||
interface FiltersForForm {
|
||||
fullText: string | undefined;
|
||||
lists: ReadonlyArray<string>;
|
||||
unlisted: boolean;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-filters',
|
||||
templateUrl: './filters.component.html',
|
||||
styleUrls: ['./filters.component.scss']
|
||||
})
|
||||
export class FiltersComponent implements OnDestroy {
|
||||
filtersForm = new FormGroup({
|
||||
text: new FormControl(),
|
||||
lists: new FormControl([] as string[]),
|
||||
});
|
||||
|
||||
lists$: Observable<ReadonlyArray<List>>;
|
||||
filters$: Observable<FiltersForForm>;
|
||||
formSubscription: Subscription;
|
||||
|
||||
constructor(private store: Store) {
|
||||
this.lists$ = this.store.pipe(select(selectLists));
|
||||
this.filters$ = this.store.pipe(
|
||||
select(selectFiltersForForm),
|
||||
tap(filters => {
|
||||
this.filtersForm.patchValue({
|
||||
text: filters.fullText,
|
||||
lists: filters.lists as string[],
|
||||
}, {emitEvent: false, onlySelf: true});
|
||||
}),
|
||||
);
|
||||
this.formSubscription = this.filtersForm.valueChanges
|
||||
.pipe(debounceTime(500))
|
||||
.subscribe(value => {
|
||||
if (value['lists'] && value['lists'].length > 0) {
|
||||
this.store.dispatch(FiltersActions.setLists({lists: value.lists}));
|
||||
this.store.dispatch(FiltersActions.setUnlisted({unlisted: false}));
|
||||
}
|
||||
if (!value['lists'] || value['lists'].length === 0) {
|
||||
this.store.dispatch(FiltersActions.setLists({lists: []}));
|
||||
}
|
||||
if (value['text']) {
|
||||
const text = value['text'];
|
||||
if (text.length > 0) {
|
||||
const parts = text.split(/[\s,]+/);
|
||||
if (parts.some((part: string) => part.startsWith('@'))) {
|
||||
const username = parts.find((part: string) => part.startsWith('@'));
|
||||
this.store.dispatch(FiltersActions.setUsername({username: username.substring(1)}));
|
||||
} else {
|
||||
this.store.dispatch(FiltersActions.setUsername({username: ''}));
|
||||
}
|
||||
this.store.dispatch(FiltersActions.setFreeText({freeText: parts.filter((part: string) => !part.startsWith('@'))}));
|
||||
} else {
|
||||
this.store.dispatch(FiltersActions.setUsername({username: ''}));
|
||||
this.store.dispatch(FiltersActions.setFreeText({freeText: []}));
|
||||
}
|
||||
} else {
|
||||
this.store.dispatch(FiltersActions.setUsername({username: ''}));
|
||||
this.store.dispatch(FiltersActions.setFreeText({freeText: []}));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.formSubscription.unsubscribe();
|
||||
}
|
||||
|
||||
clearFilter() {
|
||||
this.store.dispatch(FiltersActions.clearFilters());
|
||||
}
|
||||
|
||||
toggleUnlisted($event: boolean) {
|
||||
this.store.dispatch(FiltersActions.setUnlisted({unlisted: $event}));
|
||||
}
|
||||
}
|
||||
34
projects/mastolists/src/app/shared/guards/auth.guard.ts
Normal file
34
projects/mastolists/src/app/shared/guards/auth.guard.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import {Injectable} from '@angular/core';
|
||||
import {MastodonApiAuthenticationService} from "../../../../../mastodon-api/src/lib/services/mastodon-api-authentication.service";
|
||||
import {ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot} from "@angular/router";
|
||||
import {map, Observable, tap} from "rxjs";
|
||||
import {PersistentStore} from "../state/persistent/persistent-store.service";
|
||||
import {Store} from "@ngrx/store";
|
||||
|
||||
@Injectable({providedIn: 'root'})
|
||||
export class AuthGuard implements CanActivate {
|
||||
|
||||
constructor(private persistentStore: PersistentStore,
|
||||
private store: Store,
|
||||
private router: Router,
|
||||
private mastodonApiAuthService: MastodonApiAuthenticationService) {
|
||||
}
|
||||
|
||||
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> | boolean {
|
||||
const currentInstance = this.persistentStore.value.currentInstance;
|
||||
if (currentInstance && currentInstance.accessToken && currentInstance.accountId) return true;
|
||||
if (currentInstance && currentInstance.accessToken && !currentInstance.accountId) {
|
||||
return this.mastodonApiAuthService.verifyCredentials(currentInstance.instanceName, currentInstance.accessToken).pipe(
|
||||
map((accountId) => {
|
||||
this.persistentStore.set(
|
||||
'currentInstance',
|
||||
{...currentInstance, 'accountId': accountId}
|
||||
);
|
||||
return !!accountId;
|
||||
})
|
||||
);
|
||||
}
|
||||
this.router.navigate(['/auth']);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { AccountService } from './account.service';
|
||||
|
||||
describe('AccountService', () => {
|
||||
let service: AccountService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(AccountService);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import {Injectable} from '@angular/core';
|
||||
import {MastodonApiAccountsService} from "../../../../../mastodon-api/src/lib/services/mastodon-api-accounts.service";
|
||||
import {PersistentStore} from "../state/persistent/persistent-store.service";
|
||||
import {EMPTY, expand, map, Observable, reduce} from "rxjs";
|
||||
import {Account} from "../../../../../mastodon-api/src/lib/interfaces/public/account";
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class AccountService {
|
||||
|
||||
constructor(private mastodonApiAccountsService: MastodonApiAccountsService,
|
||||
private store: PersistentStore) {
|
||||
|
||||
}
|
||||
|
||||
loadFollowings(): Observable<ReadonlyArray<Account>> {
|
||||
const applicationState = this.store.value;
|
||||
const instanceName = applicationState.currentInstance?.instanceName;
|
||||
const accessToken = applicationState.currentInstance?.accessToken;
|
||||
const accountId = applicationState.currentInstance?.accountId;
|
||||
return this.mastodonApiAccountsService
|
||||
.getFollowingsForAccount(instanceName, accessToken!, accountId)
|
||||
.pipe(
|
||||
expand(result => {
|
||||
const nextLink = result[0];
|
||||
if (nextLink && nextLink.length > 0) {
|
||||
return this.mastodonApiAccountsService.getFollowingsForAccount(instanceName, accessToken!, accountId, nextLink);
|
||||
}
|
||||
return EMPTY;
|
||||
}),
|
||||
map(result => result[1]),
|
||||
reduce((acc: Account[], res: Account[]) => {
|
||||
return acc.concat(res);
|
||||
}, []),
|
||||
map(accounts => accounts.sort((a, b) => a.username.localeCompare(b.username)) as ReadonlyArray<Account>),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { ListService } from './list.service';
|
||||
|
||||
describe('ListService', () => {
|
||||
let service: ListService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(ListService);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
||||
79
projects/mastolists/src/app/shared/services/list.service.ts
Normal file
79
projects/mastolists/src/app/shared/services/list.service.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import {Injectable} from '@angular/core';
|
||||
import {EMPTY, expand, map, Observable, reduce, take} from "rxjs";
|
||||
import {MastodonApiListsService} from "../../../../../mastodon-api/src/lib/services/mastodon-api-lists.service";
|
||||
import {PersistentStore} from "../state/persistent/persistent-store.service";
|
||||
import {Account} from "../../../../../mastodon-api/src/lib/interfaces/public/account";
|
||||
import {List} from "../../../../../mastodon-api/src/lib/interfaces/public/list";
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ListService {
|
||||
|
||||
constructor(private mastodonApiListsService: MastodonApiListsService,
|
||||
private store: PersistentStore) {
|
||||
}
|
||||
|
||||
loadLists(): Observable<List[]> {
|
||||
const applicationState = this.store.value;
|
||||
const instanceName = applicationState.currentInstance?.instanceName;
|
||||
const accessToken = applicationState.currentInstance?.accessToken;
|
||||
return this.mastodonApiListsService
|
||||
.getLists(instanceName, accessToken!)
|
||||
.pipe(map(result => result.sort((a, b) => a.title.localeCompare(b.title))));
|
||||
}
|
||||
|
||||
loadAccountsIdsForList(listId: string): Observable<{ [id: string]: string[] }> {
|
||||
const applicationState = this.store.value;
|
||||
const instanceName = applicationState.currentInstance?.instanceName;
|
||||
const accessToken = applicationState.currentInstance?.accessToken;
|
||||
return this.mastodonApiListsService
|
||||
.getAccountsForList(instanceName, accessToken!, listId)
|
||||
.pipe(
|
||||
expand(result => {
|
||||
const nextLink = result[0];
|
||||
if (nextLink && nextLink.length > 0) {
|
||||
return this.mastodonApiListsService.getAccountsForList(instanceName, accessToken!, listId, nextLink);
|
||||
}
|
||||
return EMPTY;
|
||||
}),
|
||||
map(result => {
|
||||
const accounts = result[1];
|
||||
return {[listId]: accounts.map(account => account.id)};
|
||||
}
|
||||
),
|
||||
reduce((acc: { [listId: string]: string[] }, res: { [listId: string]: string[] }) => {
|
||||
const listId = Object.keys(res)[0];
|
||||
if (acc[listId] !== undefined && acc[listId].length > 0) {
|
||||
acc[listId] = acc[listId].concat(res[listId]);
|
||||
return acc;
|
||||
}
|
||||
return {...acc, ...res};
|
||||
}, {}),
|
||||
);
|
||||
}
|
||||
|
||||
addAccountToSelectedList(accountId: string, listId: string) {
|
||||
const applicationState = this.store.value;
|
||||
const instanceName = applicationState.currentInstance?.instanceName;
|
||||
const accessToken = applicationState.currentInstance?.accessToken;
|
||||
return this.mastodonApiListsService
|
||||
.addAccountToList(instanceName, accessToken!, listId, accountId)
|
||||
.pipe(
|
||||
take(1),
|
||||
);
|
||||
}
|
||||
|
||||
removeAccountFromList(accountId: string, listId: string) {
|
||||
const applicationState = this.store.value;
|
||||
const instanceName = applicationState.currentInstance?.instanceName;
|
||||
const accessToken = applicationState.currentInstance?.accessToken;
|
||||
return this.mastodonApiListsService
|
||||
.removeAccountFromList(instanceName, accessToken!, listId, accountId)
|
||||
.pipe(
|
||||
take(1),
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
54
projects/mastolists/src/app/shared/shared.module.ts
Normal file
54
projects/mastolists/src/app/shared/shared.module.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import {NgModule} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {AccountComponent} from './components/account/account.component';
|
||||
import {
|
||||
NbActionsModule,
|
||||
NbButtonModule,
|
||||
NbCardModule,
|
||||
NbIconModule,
|
||||
NbInputModule,
|
||||
NbSelectModule,
|
||||
NbSpinnerModule,
|
||||
NbToggleModule,
|
||||
NbUserModule
|
||||
} from "@nebular/theme";
|
||||
import {FiltersComponent} from './components/filters/filters.component';
|
||||
import {ReactiveFormsModule} from "@angular/forms";
|
||||
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
AccountComponent,
|
||||
FiltersComponent,
|
||||
],
|
||||
providers: [],
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
// Nebula import only
|
||||
NbInputModule,
|
||||
NbButtonModule,
|
||||
NbSpinnerModule,
|
||||
NbSelectModule,
|
||||
NbToggleModule,
|
||||
// Nebula
|
||||
NbCardModule,
|
||||
NbUserModule,
|
||||
NbActionsModule,
|
||||
NbIconModule,
|
||||
NbButtonModule,
|
||||
NbInputModule,
|
||||
],
|
||||
exports: [
|
||||
AccountComponent,
|
||||
FiltersComponent,
|
||||
// Nebula
|
||||
NbCardModule,
|
||||
NbActionsModule,
|
||||
NbIconModule,
|
||||
NbButtonModule,
|
||||
NbInputModule,
|
||||
]
|
||||
})
|
||||
export class SharedModule {
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import {Injectable} from "@angular/core";
|
||||
|
||||
function getLocalStorage(): Storage {
|
||||
return localStorage;
|
||||
}
|
||||
|
||||
@Injectable({providedIn: "root"})
|
||||
export class LocalStorageRefService {
|
||||
get localStorage(): Storage {
|
||||
return getLocalStorage();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import {BehaviorSubject, distinctUntilChanged, map, Observable} from "rxjs";
|
||||
import {Injectable} from "@angular/core";
|
||||
import {LocalStorageRefService} from "./local-storage-ref.service";
|
||||
import {Instance, State} from "./state";
|
||||
|
||||
const state = <State>{};
|
||||
|
||||
@Injectable({providedIn: 'root'})
|
||||
export class PersistentStore {
|
||||
private _localStorage: Storage;
|
||||
private subject = new BehaviorSubject<State>(state);
|
||||
private store = this.subject.asObservable().pipe(distinctUntilChanged());
|
||||
|
||||
constructor(private _localStorageRefService: LocalStorageRefService) {
|
||||
this._localStorage = _localStorageRefService.localStorage;
|
||||
this.loadState();
|
||||
}
|
||||
|
||||
get value() {
|
||||
return this.subject.value;
|
||||
}
|
||||
|
||||
isAuthorized(): boolean {
|
||||
const currentInstance = this.value.currentInstance;
|
||||
if (!currentInstance) return false;
|
||||
if (!currentInstance.accessToken) return false;
|
||||
if (!currentInstance.accountId) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
select<T>(name: string): Observable<T> {
|
||||
return this.store.pipe(map(value => value ? <T>value[name as keyof State] : <T>{}));
|
||||
}
|
||||
|
||||
set(name: string, state: any) {
|
||||
this.subject.next({
|
||||
...this.value, [name as keyof State]: state
|
||||
});
|
||||
this.persistState();
|
||||
}
|
||||
|
||||
private persistState() {
|
||||
const jsonData = JSON.stringify(this.subject.value, this.stringifyMap);
|
||||
this._localStorage.setItem('applicationData', jsonData);
|
||||
}
|
||||
|
||||
private loadState() {
|
||||
const jsonData = this._localStorage.getItem('applicationData');
|
||||
if (jsonData) {
|
||||
const data = JSON.parse(jsonData, this.parseMap);
|
||||
this.subject.next(data);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private stringifyMap(key: string, value: Map<string, Instance> | Map<string, string> | object) {
|
||||
if (value instanceof Map) {
|
||||
return {
|
||||
dataType: 'Map',
|
||||
value: [...value]
|
||||
};
|
||||
} else {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
private parseMap(key: string, value: any) {
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
if (value.dataType === 'Map') {
|
||||
return new Map(value.value);
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
19
projects/mastolists/src/app/shared/state/persistent/state.ts
Normal file
19
projects/mastolists/src/app/shared/state/persistent/state.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import {Account} from "../../../../../../mastodon-api/src/lib/interfaces/public/account";
|
||||
import {List} from "../../../../../../mastodon-api/src/lib/interfaces/public/list";
|
||||
|
||||
export interface Instance {
|
||||
instanceName: string;
|
||||
accessToken?: string;
|
||||
accountId: string;
|
||||
id: string;
|
||||
appName: string;
|
||||
website: string;
|
||||
redirectUrl: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
vapidKey: string;
|
||||
}
|
||||
|
||||
export interface State {
|
||||
currentInstance: Instance;
|
||||
}
|
||||
40
projects/mastolists/src/app/shared/state/store/actions.ts
Normal file
40
projects/mastolists/src/app/shared/state/store/actions.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import {createAction, createActionGroup, emptyProps, props} from "@ngrx/store";
|
||||
import {List} from "../../../../../../mastodon-api/src/lib/interfaces/public/list";
|
||||
import {Account} from "../../../../../../mastodon-api/src/lib/interfaces/public/account";
|
||||
|
||||
export const MastodonApiActions = createActionGroup({
|
||||
source: 'Mastodon API',
|
||||
events: {
|
||||
'Load Lists': emptyProps(),
|
||||
'Load Followings': emptyProps(),
|
||||
'Load Accounts For List': emptyProps(),
|
||||
'Lists Loaded': props<{ lists: ReadonlyArray<List> }>(),
|
||||
'Lists Loaded Error': emptyProps(),
|
||||
'Account Ids for Lists Loaded': props<{ mappings: { [listId: string]: string[] } }>(),
|
||||
'Followings Loaded': props<{ followings: ReadonlyArray<Account> }>(),
|
||||
'Followings Loaded Error': props<{ error: string }>(),
|
||||
}
|
||||
});
|
||||
|
||||
export const ListActions = createActionGroup({
|
||||
source: 'List',
|
||||
events: {
|
||||
'Add Account to List': props<{ accountId: string, listId: string }>(),
|
||||
'Add Account to List Success': emptyProps(),
|
||||
'Add Account to List Error': emptyProps(),
|
||||
'Remove Account from List': props<{ accountId: string, listId: string }>(),
|
||||
'Remove Account from List Success': emptyProps(),
|
||||
'Remove Account from List Error': emptyProps(),
|
||||
},
|
||||
});
|
||||
|
||||
export const FiltersActions = createActionGroup({
|
||||
source: 'Filters',
|
||||
events: {
|
||||
'Set Username': props<{ username: string }>(),
|
||||
'Set Free Text': props<{ freeText: ReadonlyArray<string> }>(),
|
||||
'Set Lists': props<{ lists: ReadonlyArray<string> }>(),
|
||||
'Set Unlisted': props<{ unlisted: boolean }>(),
|
||||
'Clear Filters': emptyProps(),
|
||||
},
|
||||
})
|
||||
108
projects/mastolists/src/app/shared/state/store/effects.ts
Normal file
108
projects/mastolists/src/app/shared/state/store/effects.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import {Injectable} from "@angular/core";
|
||||
import {act, Actions, createEffect, ofType} from "@ngrx/effects";
|
||||
import {ListActions, MastodonApiActions} from "./actions";
|
||||
import {catchError, concat, exhaustMap, forkJoin, map, merge, of, switchMap} from "rxjs";
|
||||
import {ListService} from "../../services/list.service";
|
||||
import {AccountService} from "../../services/account.service";
|
||||
import {NbToastrService} from "@nebular/theme";
|
||||
|
||||
@Injectable()
|
||||
export class ApplicationEffects {
|
||||
loadLists$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(MastodonApiActions.loadLists),
|
||||
exhaustMap(() => this.listService.loadLists().pipe(
|
||||
map(lists => MastodonApiActions.listsLoaded({lists})),
|
||||
catchError(() => of(MastodonApiActions.listsLoadedError())),
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
listsLoaded$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(MastodonApiActions.listsLoaded),
|
||||
switchMap((action) => {
|
||||
return forkJoin(action.lists.map(list => this.listService.loadAccountsIdsForList(list.id)))
|
||||
.pipe(
|
||||
map(mappings => {
|
||||
const accountsInList: { [id: string]: string[] } = mappings.reduce((acc, mapping) => {
|
||||
return {...acc, ...mapping};
|
||||
}, {});
|
||||
return MastodonApiActions.accountIdsForListsLoaded({mappings: accountsInList})
|
||||
}
|
||||
),
|
||||
);
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
loadFollowings$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(MastodonApiActions.loadFollowings),
|
||||
exhaustMap(() => this.accountService.loadFollowings().pipe(
|
||||
map(followings => MastodonApiActions.followingsLoaded({followings})),
|
||||
catchError((error) => of(MastodonApiActions.followingsLoadedError({error}))),
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
addAccountToList$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(ListActions.addAccountToList),
|
||||
exhaustMap((action) => this.listService.addAccountToSelectedList(action.accountId, action.listId).pipe(
|
||||
map(() => ListActions.addAccountToListSuccess()),
|
||||
catchError(() => of(ListActions.addAccountToListError())),
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
addAccountToListSuccess$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(ListActions.addAccountToListSuccess),
|
||||
map(() => this.toastService.success('Account added to list', 'Success')),
|
||||
), {dispatch: false}
|
||||
);
|
||||
|
||||
addCountsToListError$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(ListActions.addAccountToListError),
|
||||
map(() => this.toastService.danger('Could not add account to list, please reload', 'Error')),
|
||||
), {dispatch: false}
|
||||
);
|
||||
|
||||
removeAccountFromList$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(ListActions.removeAccountFromList),
|
||||
exhaustMap((action) => this.listService.removeAccountFromList(action.accountId, action.listId).pipe(
|
||||
map(() => ListActions.removeAccountFromListSuccess()),
|
||||
catchError(() => of(ListActions.removeAccountFromListError())),
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
removeAccountFromListSuccess$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(ListActions.removeAccountFromListSuccess),
|
||||
map(() => this.toastService.success('Account removed from list', 'Success')),
|
||||
), {dispatch: false}
|
||||
);
|
||||
|
||||
removeAccountFromListError$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(ListActions.removeAccountFromListError),
|
||||
map(() => this.toastService.danger('Could not remove account from list, please reload', 'Error')),
|
||||
), {dispatch: false}
|
||||
);
|
||||
|
||||
constructor(
|
||||
private actions$: Actions,
|
||||
private listService: ListService,
|
||||
private accountService: AccountService,
|
||||
private toastService: NbToastrService,
|
||||
) {
|
||||
}
|
||||
}
|
||||
93
projects/mastolists/src/app/shared/state/store/reducers.ts
Normal file
93
projects/mastolists/src/app/shared/state/store/reducers.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import {createReducer, on} from "@ngrx/store";
|
||||
import {Account, List} from "projects/mastodon-api/src/public-api";
|
||||
import {FiltersActions, ListActions, MastodonApiActions} from "./actions";
|
||||
|
||||
export interface Filters {
|
||||
username: string;
|
||||
freeText: ReadonlyArray<string>;
|
||||
lists: ReadonlyArray<string>;
|
||||
unlisted: boolean;
|
||||
}
|
||||
|
||||
export interface ApplicationState {
|
||||
listsLoading: boolean;
|
||||
listAccountsLoading: boolean;
|
||||
followingsLoading: boolean;
|
||||
lists: ReadonlyArray<List>;
|
||||
listsAccounts: { [listId: string]: string[] };
|
||||
followings: ReadonlyArray<Account>;
|
||||
filters: Filters;
|
||||
}
|
||||
|
||||
export const initialState: ApplicationState = {
|
||||
listsLoading: false,
|
||||
listAccountsLoading: false,
|
||||
followingsLoading: false,
|
||||
lists: [],
|
||||
listsAccounts: {},
|
||||
followings: [],
|
||||
filters: {
|
||||
username: '',
|
||||
freeText: [],
|
||||
lists: [],
|
||||
unlisted: false,
|
||||
}
|
||||
}
|
||||
|
||||
export const applicationStateReducers = createReducer(
|
||||
initialState,
|
||||
on(MastodonApiActions.loadLists, _state => {
|
||||
return {..._state, listsLoading: true};
|
||||
}),
|
||||
on(MastodonApiActions.listsLoaded, (_state, {lists}) => {
|
||||
return {..._state, listsLoading: false, listAccountsLoading: true, lists: lists,};
|
||||
}),
|
||||
on(MastodonApiActions.accountIdsForListsLoaded, (_state, {mappings}) => {
|
||||
return {..._state, listAccountsLoading: false, listsAccounts: mappings};
|
||||
}),
|
||||
on(MastodonApiActions.followingsLoaded, (_state, {followings}) => {
|
||||
return {..._state, followingsLoading: false, followings};
|
||||
}),
|
||||
on(MastodonApiActions.followingsLoadedError, (_state, {error}) => {
|
||||
console.error(error);
|
||||
return {..._state};
|
||||
}),
|
||||
on(ListActions.addAccountToList, (_state, {accountId, listId}) => {
|
||||
const existingAccountIds = _state.listsAccounts[listId] || [];
|
||||
const newAccountIds = [...existingAccountIds, accountId];
|
||||
const newMap: { [listId: string]: string[] } = {..._state.listsAccounts, [listId]: newAccountIds};
|
||||
return <ApplicationState>{
|
||||
..._state,
|
||||
listsAccounts: newMap,
|
||||
}
|
||||
}),
|
||||
on(ListActions.removeAccountFromList, (_state, {accountId, listId}) => {
|
||||
const existingAccountIds = _state.listsAccounts[listId] || [];
|
||||
const newAccountIds = existingAccountIds.filter(id => id !== accountId);
|
||||
const newMap: { [listId: string]: string[] } = {..._state.listsAccounts, [listId]: newAccountIds};
|
||||
return <ApplicationState>{
|
||||
..._state,
|
||||
listsAccounts: newMap,
|
||||
}
|
||||
}),
|
||||
on(FiltersActions.setUsername, (_state, {username}) => {
|
||||
return {..._state, filters: {..._state.filters, username}};
|
||||
}),
|
||||
on(FiltersActions.setFreeText, (_state, {freeText}) => {
|
||||
return {..._state, filters: {..._state.filters, freeText}};
|
||||
}),
|
||||
on(FiltersActions.setLists, (_state, {lists}) => {
|
||||
return {..._state, filters: {..._state.filters, lists}};
|
||||
}),
|
||||
on(FiltersActions.setUnlisted, (_state, {unlisted}) => {
|
||||
let newState = {..._state, filters: {..._state.filters, unlisted}};
|
||||
if (unlisted) {
|
||||
newState = {...newState, filters: {...newState.filters, lists: []}};
|
||||
}
|
||||
return newState;
|
||||
}),
|
||||
on(FiltersActions.clearFilters, _state => {
|
||||
return {..._state, filters: initialState.filters};
|
||||
}),
|
||||
)
|
||||
;
|
||||
114
projects/mastolists/src/app/shared/state/store/selectors.ts
Normal file
114
projects/mastolists/src/app/shared/state/store/selectors.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import {ApplicationState} from "./reducers";
|
||||
import {createFeatureSelector, createSelector, State} from "@ngrx/store";
|
||||
|
||||
|
||||
export const applicationStateFeature = createFeatureSelector<ApplicationState>('applicationState');
|
||||
|
||||
export const selectLists = createSelector(
|
||||
applicationStateFeature,
|
||||
(state: ApplicationState) => state.lists
|
||||
)
|
||||
export const selectFollowings = createSelector(
|
||||
applicationStateFeature,
|
||||
(state: ApplicationState) => state.followings
|
||||
);
|
||||
|
||||
export const selectMappings = createSelector(
|
||||
applicationStateFeature,
|
||||
(state: ApplicationState) => state.listsAccounts
|
||||
);
|
||||
|
||||
export const selectListsWithAccounts = createSelector(
|
||||
selectLists,
|
||||
selectFollowings,
|
||||
selectMappings,
|
||||
(lists, followings, mappings) => {
|
||||
return lists.map(list => {
|
||||
const accountIds = mappings[list.id];
|
||||
const accounts = followings.filter(account => accountIds.includes(account.id));
|
||||
return {...list, accounts};
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export const selectFollowingsWithLists = createSelector(
|
||||
selectFollowings,
|
||||
selectLists,
|
||||
selectMappings,
|
||||
(followings, lists, mappings) => {
|
||||
return followings.map(account => {
|
||||
const listIds = Object.keys(mappings).filter(listId => mappings[listId].includes(account.id));
|
||||
const accountLists = lists.filter(list => listIds.includes(list.id));
|
||||
return {...account, lists: accountLists};
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export const selectFilters = createSelector(
|
||||
applicationStateFeature,
|
||||
(state: ApplicationState) => state.filters
|
||||
);
|
||||
|
||||
export const selectFiltersForForm = createSelector(
|
||||
applicationStateFeature,
|
||||
(state: ApplicationState) => {
|
||||
const filters = state.filters;
|
||||
const parts = [];
|
||||
if (filters.username) {
|
||||
parts.push(`@${filters.username}`);
|
||||
}
|
||||
parts.push(...filters.freeText);
|
||||
const fullText = parts.join(',');
|
||||
return {
|
||||
fullText: fullText.length > 0 ? fullText : undefined,
|
||||
lists: filters.lists,
|
||||
unlisted: filters.unlisted,
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const selectFilteredFollowingsWithLists = createSelector(
|
||||
selectFollowingsWithLists,
|
||||
selectFilters,
|
||||
(followings, filters) => {
|
||||
return followings.filter(account => {
|
||||
const usernameMatch = filters.username.length === 0 || account.username.toLowerCase().startsWith(filters.username.toLowerCase());
|
||||
const freeTextMatch = filters.freeText.length === 0 || filters.freeText
|
||||
.every(text => {
|
||||
const lowerCaseText = text.toLowerCase();
|
||||
return account.username.toLowerCase().includes(lowerCaseText)
|
||||
|| account.note.toLowerCase().includes(lowerCaseText)
|
||||
|| account.fields.some(field => field.name.toLowerCase().includes(lowerCaseText))
|
||||
|| account.fields.some(field => field.value.toLowerCase().includes(lowerCaseText));
|
||||
});
|
||||
const listsMatch = filters.lists.length === 0 || filters.lists.some(listId => account.lists.some(list => list.id === listId));
|
||||
const unlistedMatch = filters.unlisted ? account.lists.length === 0 : true;
|
||||
return usernameMatch && freeTextMatch && listsMatch && unlistedMatch;
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export const selectLoading = createSelector(
|
||||
applicationStateFeature,
|
||||
(state: ApplicationState) => state.listsLoading || state.listAccountsLoading || state.followingsLoading
|
||||
);
|
||||
|
||||
export const selectLoadingPercentage = createSelector(
|
||||
applicationStateFeature,
|
||||
(state: ApplicationState) => {
|
||||
if (!state.listsLoading && !state.listAccountsLoading && !state.followingsLoading) {
|
||||
return 100;
|
||||
}
|
||||
if (state.listsLoading && !state.listAccountsLoading && !state.followingsLoading) {
|
||||
return 50;
|
||||
}
|
||||
if (!state.listsLoading && state.listAccountsLoading && !state.followingsLoading) {
|
||||
return 80;
|
||||
}
|
||||
if (!state.listsLoading && !state.listAccountsLoading && state.followingsLoading) {
|
||||
return 50;
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
);
|
||||
|
||||
17
projects/mastolists/src/app/sync/sync-routing.module.ts
Normal file
17
projects/mastolists/src/app/sync/sync-routing.module.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import {RouterModule, Routes} from "@angular/router";
|
||||
import {NgModule} from "@angular/core";
|
||||
import {SyncComponent} from "./sync/sync.component";
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: SyncComponent
|
||||
}
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class SyncRoutingModule {
|
||||
}
|
||||
23
projects/mastolists/src/app/sync/sync.module.ts
Normal file
23
projects/mastolists/src/app/sync/sync.module.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import {NgModule} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {SyncComponent} from './sync/sync.component';
|
||||
import {SharedModule} from "../shared/shared.module";
|
||||
import {SyncRoutingModule} from "./sync-routing.module";
|
||||
import {NbCardModule, NbSpinnerModule} from "@nebular/theme";
|
||||
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
SyncComponent
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
SharedModule,
|
||||
SyncRoutingModule,
|
||||
// Nebula
|
||||
NbCardModule,
|
||||
NbSpinnerModule,
|
||||
]
|
||||
})
|
||||
export class SyncModule {
|
||||
}
|
||||
50
projects/mastolists/src/app/sync/sync/sync.component.html
Normal file
50
projects/mastolists/src/app/sync/sync/sync.component.html
Normal file
@@ -0,0 +1,50 @@
|
||||
<nb-card>
|
||||
<nb-card-header>
|
||||
<h1>Sync Remote Data with your Local Storage</h1>
|
||||
</nb-card-header>
|
||||
<nb-card-body>
|
||||
<nb-card>
|
||||
<nb-card-header>
|
||||
Currently in Local Store
|
||||
</nb-card-header>
|
||||
<nb-card-body>
|
||||
<table style="width: 100%;">
|
||||
<tr>
|
||||
<th colspan="2">Followings</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Number of followings</td>
|
||||
<td>{{(followings$ | async)?.length}}</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div class="divider"></div>
|
||||
<table style="width: 100%;">
|
||||
<tr>
|
||||
<th colspan="2">Number of Accounts in List</th>
|
||||
</tr>
|
||||
<tr *ngFor="let list of lists$ | async">
|
||||
<td>{{list?.title}}</td>
|
||||
<td>{{list?.accounts?.length}}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</nb-card-body>
|
||||
<nb-card-footer>
|
||||
<div>
|
||||
<button nbButton type="button"
|
||||
[nbSpinner]="(loading$ | async)!"
|
||||
[disabled]="(loading$ | async)!"
|
||||
(click)="loadListsAndAccounts()">Get Lists and Accounts
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div *ngIf="(lists$ | async) && (followings$ | async)">
|
||||
<p>Now you can:</p>
|
||||
<ul>
|
||||
<li>Select the <a [routerLink]="['/followings']">List view</a> to add and remove users from lists</li>
|
||||
<li>... or use the experimental <a [routerLink]="['/matrix']">Matrix view</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</nb-card-footer>
|
||||
</nb-card>
|
||||
</nb-card-body>
|
||||
</nb-card>
|
||||
23
projects/mastolists/src/app/sync/sync/sync.component.spec.ts
Normal file
23
projects/mastolists/src/app/sync/sync/sync.component.spec.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { SyncComponent } from './sync.component';
|
||||
|
||||
describe('SyncComponent', () => {
|
||||
let component: SyncComponent;
|
||||
let fixture: ComponentFixture<SyncComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [ SyncComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(SyncComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
29
projects/mastolists/src/app/sync/sync/sync.component.ts
Normal file
29
projects/mastolists/src/app/sync/sync/sync.component.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import {Component} from '@angular/core';
|
||||
import {select, Store} from "@ngrx/store";
|
||||
import {MastodonApiActions} from "../../shared/state/store/actions";
|
||||
import {selectFollowings, selectLists, selectListsWithAccounts, selectLoading} from "../../shared/state/store/selectors";
|
||||
import {Observable, tap} from "rxjs";
|
||||
import {Account, List} from 'projects/mastodon-api/src/public-api';
|
||||
|
||||
@Component({
|
||||
selector: 'app-sync',
|
||||
templateUrl: './sync.component.html',
|
||||
styleUrls: ['./sync.component.scss']
|
||||
})
|
||||
export class SyncComponent {
|
||||
loading$: Observable<boolean>;
|
||||
followings$: Observable<ReadonlyArray<Account>>;
|
||||
lists$: Observable<ReadonlyArray<List>>;
|
||||
|
||||
constructor(private store: Store) {
|
||||
this.followings$ = this.store.pipe(select(selectFollowings))
|
||||
this.lists$ = this.store.pipe(select(selectListsWithAccounts));
|
||||
this.loading$ = this.store.pipe(select(selectLoading));
|
||||
}
|
||||
|
||||
loadListsAndAccounts() {
|
||||
this.store.dispatch(MastodonApiActions.loadLists());
|
||||
this.store.dispatch(MastodonApiActions.loadFollowings());
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user