feat: adds list editor

feat: adds list editor
This commit is contained in:
2022-12-23 15:15:11 +01:00
parent 08dcfd179b
commit 74e7d8c9e7
36 changed files with 389 additions and 182 deletions

View File

@@ -8,6 +8,18 @@ const routes: Routes = [
redirectTo: 'home',
pathMatch: 'full'
},
{
path: 'list',
redirectTo: 'followings/list',
},
{
path: 'matrix',
redirectTo: 'followings/matrix',
},
{
path: 'table',
redirectTo: 'followings/table',
},
{
path: 'home',
loadChildren: () => import('./home/home.module').then(m => m.HomeModule),
@@ -22,20 +34,16 @@ const routes: Routes = [
canActivate: [AuthGuard],
},
{
path: 'list',
loadChildren: () => import('./followings-list/followings-list.module').then(m => m.FollowingsListModule),
path: 'lists',
loadChildren: () => import('./lists/lists.module').then(m => m.ListsModule),
canActivate: [AuthGuard],
},
{
path: 'matrix',
loadChildren: () => import('./followings-matrix/followings-matrix.module').then(m => m.FollowingsMatrixModule),
canActivate: [AuthGuard],
},
{
path: 'table',
loadChildren: () => import('./followings-table/followings-table.module').then(m => m.FollowingsTableModule),
path: 'followings',
loadChildren: () => import('./followings/followings.module').then(m => m.FollowingsModule),
canActivate: [AuthGuard],
},
];
@NgModule({

View File

@@ -14,9 +14,10 @@ export class AppComponent {
navigationItems = [
{title: 'Authorize', link: '/auth'},
{title: 'Stats', link: '/sync'},
{title: 'List view', link: '/list'},
{title: 'Matrix View', link: '/matrix'},
{title: 'Table View', link: '/table'},
{title: 'Edit Lists', link: '/lists'},
{title: 'List view', link: '/followings/list'},
{title: 'Matrix View', link: '/followings/matrix'},
{title: 'Table View', link: '/followings/table'},
];
constructor(private store: Store, private persistentStore: PersistentStore) {

View File

@@ -1,17 +0,0 @@
import {RouterModule, Routes} from "@angular/router";
import {NgModule} from "@angular/core";
import {ListComponent} from "./list/list.component";
const routes: Routes = [
{
path: '',
component: ListComponent
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class FollowingsListRoutingModule {
}

View File

@@ -1,23 +0,0 @@
import {NgModule} from '@angular/core';
import {CommonModule} from '@angular/common';
import {ListComponent} from './list/list.component';
import {SharedModule} from '../shared/shared.module';
import {FollowingsListRoutingModule} from './followings-list-routing.module';
import {NbListModule, NbToggleModule} from "@nebular/theme";
@NgModule({
declarations: [
ListComponent
],
imports: [
CommonModule,
SharedModule,
FollowingsListRoutingModule,
// Nebula
NbListModule,
NbToggleModule,
]
})
export class FollowingsListModule {
}

View File

@@ -1,17 +0,0 @@
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 {
}

View File

@@ -1,25 +0,0 @@
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 {
}

View File

@@ -1,22 +0,0 @@
import {NgModule} from '@angular/core';
import {CommonModule} from '@angular/common';
import {TableComponent} from './table/table.component';
import {SharedModule} from "../shared/shared.module";
import {FollowingsTableRoutingModule} from "./followings-table-routing.module";
import {NbUserModule} from "@nebular/theme";
@NgModule({
declarations: [
TableComponent
],
imports: [
CommonModule,
SharedModule,
FollowingsTableRoutingModule,
// Nebular
NbUserModule,
]
})
export class FollowingsTableModule {
}

View File

@@ -0,0 +1,27 @@
import {RouterModule, Routes} from "@angular/router";
import {NgModule} from "@angular/core";
import {ListComponent} from "./list/list.component";
import {MatrixComponent} from "./matrix/matrix.component";
import {TableComponent} from "./table/table.component";
const routes: Routes = [
{
path: 'list',
component: ListComponent,
},
{
path: 'matrix',
component: MatrixComponent,
},
{
path: 'table',
component: TableComponent,
},
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class FollowingsRoutingModule {
}

View File

@@ -0,0 +1,31 @@
import {NgModule} from '@angular/core';
import {CommonModule} from '@angular/common';
import {SharedModule} from "../shared/shared.module";
import {FollowingsRoutingModule} from "./followings-list-routing.module";
import {NbBadgeModule, NbCheckboxModule, NbIconModule, NbListModule, NbPopoverModule, NbToggleModule, NbUserModule} from "@nebular/theme";
import {ListComponent} from "./list/list.component";
import {TableComponent} from "./table/table.component";
import {MatrixComponent} from "./matrix/matrix.component";
@NgModule({
declarations: [
ListComponent,
TableComponent,
MatrixComponent,
],
imports: [
CommonModule,
SharedModule,
FollowingsRoutingModule,
// Nebula
NbCheckboxModule,
NbListModule,
NbToggleModule,
NbBadgeModule,
NbPopoverModule,
NbUserModule,
]
})
export class FollowingsModule {
}

View File

@@ -8,8 +8,8 @@
<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]="['/list']">List view</a> to add and remove users from lists</li>
<li>... or use the experimental <a [routerLink]="['/matrix']">Matrix view</a></li>
<li>Select the <a [routerLink]="['/followings/list']">List view</a> to add and remove users from lists</li>
<li>... or use the experimental <a [routerLink]="['/followings/matrix']">Matrix view</a></li>
</ul>
</nb-card-body>
</nb-card>

View File

@@ -1,11 +1,11 @@
import {RouterModule, Routes} from "@angular/router";
import {NgModule} from "@angular/core";
import {TableComponent} from "./table/table.component";
import {ListsComponent} from "./lists/lists.component";
const routes: Routes = [
{
path: '',
component: TableComponent
component: ListsComponent
}
];
@@ -13,5 +13,5 @@ const routes: Routes = [
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class FollowingsTableRoutingModule {
export class ListsRoutingModule {
}

View File

@@ -0,0 +1,24 @@
import {NgModule} from '@angular/core';
import {CommonModule} from '@angular/common';
import {ListsComponent} from './lists/lists.component';
import {SharedModule} from "../shared/shared.module";
import {ListsRoutingModule} from "./lists-routing.module";
import {NbSpinnerModule} from "@nebular/theme";
import {ReactiveFormsModule} from "@angular/forms";
@NgModule({
declarations: [
ListsComponent
],
imports: [
CommonModule,
SharedModule,
ListsRoutingModule,
ReactiveFormsModule,
// Nebular
NbSpinnerModule,
]
})
export class ListsModule {
}

View File

@@ -0,0 +1,31 @@
<nb-card>
<nb-card-header>
<h1>Lists</h1>
<div class="divider"></div>
<div [formGroup]="newListForm">
<input nbInput id="title" type="text" [formControlName]="'title'" style="margin-right: 20px;">
<button class="new-list-button" nbButton type="button"
[nbSpinner]="creatingList"
(click)="createList()">Create List
</button>
</div>
</nb-card-header>
<nb-card-body>
<table>
<tr>
<th>Id</th>
<th>Title</th>
</tr>
<tr *ngFor="let list of lists$ | async">
<td>
<a [href]="'https://' + instanceName + '/lists/' + list.id" target="_blank" rel="noopener">
{{list.id}}
</a>
</td>
<td>
<input nbInput id="serverUrl" type="text" [value]="list.title" (change)="listNameChanged(list.id, $event)">
</td>
</tr>
</table>
</nb-card-body>
</nb-card>

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ListsComponent } from './lists.component';
describe('ListsComponent', () => {
let component: ListsComponent;
let fixture: ComponentFixture<ListsComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ ListsComponent ]
})
.compileComponents();
fixture = TestBed.createComponent(ListsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,43 @@
import {Component} from '@angular/core';
import {List} from 'projects/mastodon-api/src/public-api';
import {Observable} from "rxjs";
import {select, Store} from "@ngrx/store";
import {selectLists} from "../../shared/state/store/selectors";
import {PersistentStore} from "../../shared/state/persistent/persistent-store.service";
import {ListActions} from "../../shared/state/store/actions";
import {FormControl, FormGroup} from "@angular/forms";
@Component({
selector: 'app-lists',
templateUrl: './lists.component.html',
styleUrls: ['./lists.component.scss']
})
export class ListsComponent {
lists$: Observable<ReadonlyArray<List>>;
instanceName: string;
creatingList: boolean = false;
newListForm = new FormGroup({
title: new FormControl(''),
});
constructor(private store: Store, private persistentStore: PersistentStore) {
this.instanceName = persistentStore.value.currentInstance.instanceName;
this.lists$ = this.store.pipe(select(selectLists));
}
listNameChanged(id: string, event: Event) {
const target = event.target as HTMLInputElement;
const newTitle = target.value;
this.store.dispatch(ListActions.updateList({listId: id, newTitle}));
}
createList() {
let title = this.newListForm.value.title ?? '';
if (title.length > 0) {
this.store.dispatch(ListActions.createList({title}));
this.newListForm.reset();
}
}
}

View File

@@ -23,6 +23,30 @@ export class ListService {
.pipe(map(result => result.sort((a, b) => a.title.localeCompare(b.title))));
}
createList(title: string) {
const applicationState = this.store.value;
const instanceName = applicationState.currentInstance?.instanceName;
const accessToken = applicationState.currentInstance?.accessToken;
return this.mastodonApiListsService
.createList(instanceName, accessToken!, title)
.pipe(
take(1),
);
}
updateList(listId: string, newName: string) {
const applicationState = this.store.value;
const instanceName = applicationState.currentInstance?.instanceName;
const accessToken = applicationState.currentInstance?.accessToken;
return this.mastodonApiListsService
.updateList(instanceName, accessToken!, listId, newName)
.pipe(
take(1),
);
}
loadAccountsIdsForList(listId: string): Observable<{ [id: string]: string[] }> {
const applicationState = this.store.value;
const instanceName = applicationState.currentInstance?.instanceName;
@@ -74,6 +98,5 @@ export class ListService {
take(1),
)
}
}

View File

@@ -25,6 +25,12 @@ export const ListActions = createActionGroup({
'Remove Account from List': props<{ accountId: string, listId: string }>(),
'Remove Account from List Success': emptyProps(),
'Remove Account from List Error': emptyProps(),
'Create List': props<{ title: string }>(),
'Create List Success': props<{ listId: string, title: string }>(),
'Create List Error': emptyProps(),
'Update List': props<{ listId: string, newTitle: string }>(),
'Update List Success': props<{ listId: string, newTitle: string }>(),
'Update List Error': emptyProps(),
},
});

View File

@@ -1,10 +1,11 @@
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 {catchError, concat, exhaustMap, forkJoin, map, merge, of, switchMap, tap} from "rxjs";
import {ListService} from "../../services/list.service";
import {AccountService} from "../../services/account.service";
import {NbToastrService} from "@nebular/theme";
import {List} from "projects/mastodon-api/src/public-api";
@Injectable()
export class ApplicationEffects {
@@ -19,6 +20,59 @@ export class ApplicationEffects {
)
);
updateList$ = createEffect(() =>
this.actions$.pipe(
ofType(ListActions.updateList),
exhaustMap((action) => this.listService.updateList(action.listId, action.newTitle).pipe(
map((resp) => {
const list = resp as List;
return ListActions.updateListSuccess({listId: list.id, newTitle: list.title});
}),
catchError(() => of(ListActions.updateListError)),
)
)
)
);
updateListSuccess$ = createEffect(() =>
this.actions$.pipe(
ofType(ListActions.updateListSuccess),
map(() => this.toastService.success('List updated successfully', 'Success')),
), {dispatch: false}
);
updateListError$ = createEffect(() =>
this.actions$.pipe(
ofType(ListActions.updateListError),
map(() => this.toastService.success('Could not update the list', 'Error')),
), {dispatch: false}
);
createList$ = createEffect(() =>
this.actions$.pipe(
ofType(ListActions.createList),
exhaustMap((action) => this.listService.createList(action.title).pipe(
map((resp) => {
const list = resp as List;
return ListActions.createListSuccess({listId: list.id, title: list.title});
}),
catchError(() => of(ListActions.createListError())),
)
)
)
);
createListSuccess$ = createEffect(() =>
this.actions$.pipe(
ofType(ListActions.createListSuccess),
map(() => this.toastService.success('List created successfully', 'Success')),
), {dispatch: false}
);
createListError$ = createEffect(() =>
this.actions$.pipe(
ofType(ListActions.createListError),
map(() => this.toastService.success('Could not create the list', 'Error')),
), {dispatch: false}
);
listsLoaded$ = createEffect(() =>
this.actions$.pipe(
ofType(MastodonApiActions.listsLoaded),

View File

@@ -35,59 +35,74 @@ export const initialState: ApplicationState = {
}
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};
}),
)
;
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};
}),
on(ListActions.createListSuccess, (_state, {listId, title}) => {
const newListsArray = [..._state.lists, {id: listId, title: title, repliesPolicy: 'list', accounts: []}].sort((a, b) => a.title.localeCompare(b.title));
return {
..._state,
listsAccounts: {..._state.listsAccounts, [listId]: []},
lists: newListsArray,
};
}),
on(ListActions.updateListSuccess, (_state, {listId, newTitle}) => {
const listToUpdate = _state.lists.find(list => list.id === listId) as List;
const newListsArray = [..._state.lists.filter(list => list.id !== listId), {...listToUpdate, title: newTitle}].sort((a, b) => a.title.localeCompare(b.title));
return {
..._state,
lists: newListsArray,
};
}),
);

View File

@@ -40,8 +40,9 @@
<div *ngIf="(lists$ | async) && (followings$ | async)">
<p>Now you can:</p>
<ul>
<li>Select the <a [routerLink]="['/list']">List view</a> to add and remove users from lists</li>
<li>... or use the experimental <a [routerLink]="['/matrix']">Matrix view</a></li>
<li>Edit the lists <a [routerLink]="['/lists']">Lists</a></li>
<li>Select the <a [routerLink]="['/followings/list']">List view</a> to add and remove users from lists</li>
<li>... or use the experimental <a [routerLink]="['/followings/matrix']">Matrix view</a></li>
</ul>
</div>
</nb-card-footer>