feat: adds primeng and novaloop design

This commit is contained in:
2023-02-20 11:41:58 +01:00
parent 9521cae1dc
commit a17f8de752
227 changed files with 39399 additions and 22040 deletions

View File

@@ -30,7 +30,20 @@
"projects/mastolists/src/assets"
],
"styles": [
"projects/mastolists/src/styles.scss"
"node_modules/primeng/resources/primeng.css",
"node_modules/primeicons/primeicons.css",
"projects/mastolists/src/styles.scss",
{
"input": "projects/mastolists/src/styles/novaloop-light/theme.css",
"bundleName": "light",
"inject": false
},
{
"input": "projects/mastolists/src/styles/novaloop-dark/theme.css",
"bundleName": "dark",
"inject": false
}
],
"scripts": []
},
@@ -120,6 +133,9 @@
"projects/mastolists/src/assets"
],
"styles": [
{
"input": "node_modules/@progress/kendo-theme-default/dist/all.css"
},
"projects/mastolists/src/styles.scss"
],
"scripts": []

1
kendo-ui-license.txt Normal file
View File

@@ -0,0 +1 @@
eyJhbGciOiJSUzI1NiIsInR5cCI6IkxJQyJ9.eyJwcm9kdWN0cyI6W3sidHJpYWwiOnRydWUsImNvZGUiOiJLRU5ET1VJQU5HVUxBUiIsImxpY2Vuc2VFeHBpcmF0aW9uRGF0ZSI6MTY3ODI0MzkxOH1dLCJpbnRlZ3JpdHkiOiJrNVc5MEp4VkRXb3JtVUJKSlpDcU1Wd2FrQm89IiwibGljZW5zZUhvbGRlciI6Im1hcmt1cy5odWdnbGVyQG5vdmFsb29wLmNoIiwiaWF0IjoxNjc1NjkxNTk4LCJhdWQiOiJtYXJrdXMuaHVnZ2xlckBub3ZhbG9vcC5jaCIsInVzZXJJZCI6IjNiZTM4N2M0LWFmNTQtNDQ1NS05NDM3LWVkNThlYzZjYzY3YSJ9.vqQ372HPP6IHfJHfWqRQB1aSXTp0IQ6YWA_Ns4fDrd0Mn4epysyHonWvRpLvM8N8mTKJbvw4NSxAIVCyBIAlxv1gYvIvX5xprV0KLs_xXD2VdoBaEIBWzHkZrY9htTqM3Rng9LFDu4jeYKtVxo7YXDWTuLwP-n3sgBKnZYuasyY6MxdOVCw9YRnqUUURPxI7KIlOODbiNtazl2wrsXQZvRjG41XvgIByxoQ6EAHndVA7yYQOOFewbRBOkxcq_YAfeak4NE6nKsU7A3ZaooFqBdZbdsbK-KYaaf_zC_oXI-t9NMr53BOI04EGIOmQ8PZtCAhL8F0LBiuT68TKxo-ujQ

9093
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,41 +12,41 @@
},
"private": true,
"dependencies": {
"@angular/animations": "^15.0.0",
"@angular/common": "^15.0.0",
"@angular/compiler": "^15.0.0",
"@angular/core": "^15.0.0",
"@angular/forms": "^15.0.0",
"@angular/platform-browser": "^15.0.0",
"@angular/platform-browser-dynamic": "^15.0.0",
"@angular/router": "^15.0.0",
"@nebular/auth": "10.0.0",
"@nebular/eva-icons": "^10.0.0",
"@nebular/security": "10.0.0",
"@nebular/theme": "10.0.0",
"@ngrx/effects": "^15.0.0",
"@ngrx/entity": "^15.0.0",
"@ngrx/store": "^15.0.0",
"@ngrx/store-devtools": "^15.0.0",
"@angular/animations": "^15.1.4",
"@angular/common": "^15.1.4",
"@angular/compiler": "^15.1.4",
"@angular/core": "^15.1.4",
"@angular/forms": "^15.1.4",
"@angular/localize": "^15.1.4",
"@angular/platform-browser": "^15.1.4",
"@angular/platform-browser-dynamic": "^15.1.4",
"@angular/router": "^15.1.4",
"@ngrx/effects": "^15.2.1",
"@ngrx/entity": "^15.2.1",
"@ngrx/store": "^15.2.1",
"@ngrx/store-devtools": "^15.2.1",
"axios": "^1.2.1",
"eva-icons": "^1.1.3",
"ngx-progressbar": "^9.0.0",
"oauth": "^0.10.0",
"object-assign-deep": "^0.4.0",
"parse-link-header": "^2.0.0",
"primeicons": "^6.0.1",
"primeng": "^15.2.0",
"rxjs": "~7.5.0",
"tslib": "^2.3.0",
"zone.js": "~0.12.0"
},
"devDependencies": {
"@angular-builders/custom-webpack": "^15.0.0-beta.0",
"@angular-devkit/build-angular": "^15.0.3",
"@angular/cli": "~15.0.2",
"@angular-devkit/build-angular": "^15.1.5",
"@angular/cli": "~15.1.5",
"@angular/compiler-cli": "^15.0.0",
"@types/jasmine": "~4.3.0",
"@types/oauth": "^0.9.1",
"@types/object-assign-deep": "^0.4.0",
"@types/parse-link-header": "^2.0.0",
"autoprefixer": "^10.4.13",
"jasmine-core": "~4.5.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.1.0",
@@ -54,28 +54,9 @@
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.0.0",
"ng-packagr": "^15.0.0",
"postcss": "^8.4.21",
"tailwindcss": "^3.2.6",
"typescript": "~4.8.2"
},
"overrides": {
"@nebular/auth@10.0.0": {
"@angular/animations": "15.0.3",
"@angular/cdk": "15.0.0",
"@angular/common": "15.0.3",
"@angular/core": "15.0.3",
"@angular/forms": "15.0.3",
"@angular/router": "15.0.3"
},
"@nebular/theme@10.0.0": {
"@angular/animations": "15.0.3",
"@angular/cdk": "15.0.0",
"@angular/common": "15.0.3",
"@angular/core": "15.0.3",
"@angular/router": "15.0.3"
},
"@nebular/security@10.0.0": {
"@angular/common": "15.0.3",
"@angular/core": "15.0.3",
"@angular/router": "15.0.3"
}
}
"overrides": {}
}

View File

@@ -1,10 +1,11 @@
import {Injectable} from '@angular/core';
import {catchError, map, Observable, throwError} from "rxjs";
import {catchError, map, Observable, tap, throwError} from "rxjs";
import {RegisterAppResponse} from "../interfaces/responses/register_app_response";
import {GetAccessTokenResponse} from "../interfaces/responses/get_access_token_response";
import {VerifyCredentialsResponse} from "../interfaces/responses/verify_credentials_response";
import {MastodonApiService} from "./mastodon-api.service";
import {RegisteredApp} from "../interfaces/public/registered_app";
import {Account} from "../interfaces/public/account";
@Injectable({
providedIn: 'root'
@@ -57,11 +58,13 @@ export class MastodonApiAuthenticationService {
window.location.href = `https://${instance}/oauth/authorize?${parameters}`;
}
verifyCredentials(instance: string, accessToken: string): Observable<string> {
verifyCredentials(instance: string, accessToken: string): Observable<Account> {
const url = `https://${instance}/api/v1/accounts/verify_credentials`;
return this.mastodonApiService
.getAuthenticated<VerifyCredentialsResponse>(url, accessToken)
.pipe(map((response) => response.id));
.pipe(
map((response) => response as Account)
);
}

View File

@@ -1,61 +1,11 @@
<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>
<h2>{{appName}}</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>
<div [ngClass]="{'dark': themeService.isDarkThemeEnabled()}" class="flex flex-col h-screen justify-between bg.">
<app-header></app-header>
<div class="flex-grow bg-white dark:bg-denim-darker">
<router-outlet></router-outlet>
</nb-layout-column>
</div>
<app-footer></app-footer>
</div>
<nb-layout-footer>
<div class="footer-container">
<div class="col col-left">
Novaloop AG<br/>
Niederdorfstrasse 88<br/>
8001 Zürich<br/>
</div>
<div class="col col-center">
<div class="mastodon-link">
<img src="assets/images/mastodon.svg" alt="Mastodon logo" class="mastodon-logo">
<a href="https://novaloop.social/@magbeat" target="_blank" rel="noreferrer">@magbeat@novaloop.social</a><br>
</div>
<div class="mastodon-link">
<img src="assets/images/mastodon.svg" alt="Mastodon logo" class="mastodon-logo">
<a href="https://novaloop.social/@langhard" target="_blank" rel="noreferrer">@langhard@novaloop.social</a><br>
</div>
<div class="mastodon-link">
<img src="assets/images/mastodon.svg" alt="Mastodon logo" class="mastodon-logo">
<a href="https://novaloop.social/@snowping" target="_blank" rel="noreferrer">@snowping@novaloop.social</a><br>
</div>
</div>
<div class="col col-right">
<a href="tel:+41 44 500 54 60">+41 44 500 54 60</a><br/>
<a href="https://www.novaloop.ch" target="_blank" rel="noreferrer">www.novaloop.ch</a><br/>
<a href="mailto:mail@novaloop.ch">mail@novaloop.ch</a><br/>
<a href="https://git.novaloop.ch/novaloop-oss/mastodon-apps" target="_blank" rel="noreferrer">Source Code (v{{version}})</a><br>
<a href="https://git.novaloop.ch/novaloop-oss/mastodon-apps/issues" target="_blank" rel="noreferrer">Issues</a>
<br/>
</div>
</div>
</nb-layout-footer>
</nb-layout>
<p-toast position="bottom-right"></p-toast>

View File

@@ -1,78 +0,0 @@
.button-responsive {
display: none
}
#logo {
display: flex;
flex-direction: row;
align-items: center;
a {
margin-right: 15px;
}
}
img.mastodon-logo {
width: 15px;
margin-right: 10px;
}
.footer-container {
width: 100%;
display: flex;
flex-direction: row;
justify-content: space-between;
}
.col-center {
display: flex;
flex-direction: column;
.mastodon-link {
text-align: center;
display: flex;
align-items: flex-start;
}
}
.col-right {
text-align: right;
}
@media (max-width: 573px) {
.footer-container {
width: 100%;
display: flex;
flex-direction: column;
}
.button-responsive {
display: inline-block
}
.menu-responsive {
display: none
}
.col {
padding-bottom: 20px;
}
.col-center {
text-align: left;
}
.col-right {
text-align: left;
}
}
nb-action {
cursor: pointer;
}
.navbar-container {
display: flex;
flex-direction: row;
justify-content: space-between;
width: 100%;
align-items: center;
}

View File

@@ -2,28 +2,25 @@ import {Component, isDevMode} from '@angular/core';
import {select, Store} from "@ngrx/store";
import {fromApplication, fromAuthorize, selectIsLoggedIn} from "./shared/state/store";
import {environment} from "../environments/environment";
// @ts-ignore
import packageJson from '../../../../package.json';
import {ThemeService} from "./shared/services/theme.service";
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss']
styleUrls: ['./app.component.scss'],
})
export class AppComponent {
appName = environment.appName;
version = packageJson.version;
navigationItems = [
{title: 'Authorize', link: '/authorize'},
{title: 'Stats', link: '/sync'},
{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) {
constructor(private store: Store, public themeService: ThemeService) {
const darkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
if (darkMode) {
this.themeService.setTheme('dark');
}
this.store.dispatch(fromAuthorize.loadLocalStorage());
this.store.pipe(
select(selectIsLoggedIn),

View File

@@ -4,10 +4,8 @@ import {BrowserModule} from '@angular/platform-browser';
import {AppRoutingModule} from './app-routing.module';
import {AppComponent} from './app.component';
import {MastodonApiModule} from "projects/mastodon-api/src/public-api";
import {NbActionsModule, NbContextMenuModule, NbDialogModule, NbGlobalPhysicalPosition, NbLayoutModule, NbMenuModule, 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 {ActionReducer, StoreModule} from '@ngrx/store';
import {StoreDevtoolsModule} from "@ngrx/store-devtools";
import {applicationStateReducer, authenticationStateReducer} from "./shared/state/store/reducers";
@@ -16,13 +14,12 @@ import {NgProgressModule} from "ngx-progressbar";
import {NgProgressHttpModule} from "ngx-progressbar/http";
import {AuthorizationModule} from "./authorization/authorization.module";
import {ApplicationStateEffects, AuthorizationStateEffects, fromAuthorize} from "./shared/state/store";
import {HeaderComponent} from './layout/header/header.component';
import {FooterComponent} from './layout/footer/footer.component';
import {ToastModule} from "primeng/toast";
import {InputSwitchModule} from "primeng/inputswitch";
import {FormsModule} from "@angular/forms";
const toastrConfig = {
duration: 3000,
position: NbGlobalPhysicalPosition.BOTTOM_RIGHT,
preventDuplicates: true,
destroyByClick: true,
};
export function handlePersistentState(reducer: ActionReducer<any>): ActionReducer<any> {
return function (state, action) {
@@ -50,6 +47,8 @@ export const metaReducers = [handlePersistentState];
bootstrap: [AppComponent],
declarations: [
AppComponent,
HeaderComponent,
FooterComponent,
],
imports: [
BrowserModule,
@@ -76,15 +75,9 @@ export const metaReducers = [handlePersistentState];
SharedModule,
NgProgressModule,
NgProgressHttpModule,
// Nebula
NbContextMenuModule,
NbMenuModule.forRoot(),
NbDialogModule.forRoot(),
NbEvaIconsModule,
NbActionsModule,
NbThemeModule.forRoot({name: 'dark'}),
NbToastrModule.forRoot(toastrConfig),
NbLayoutModule,
ToastModule,
InputSwitchModule,
FormsModule,
],
providers: []
})

View File

@@ -4,7 +4,6 @@ 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";
import {AuthGuard} from "./guards/auth.guard";
@NgModule({
@@ -16,8 +15,6 @@ import {AuthGuard} from "./guards/auth.guard";
CommonModule,
SharedModule,
ReactiveFormsModule,
// Nebula
NbSpinnerModule,
]
})
export class AuthorizationModule {

View File

@@ -1,30 +1,53 @@
<app-page>
<div title>Authorize with your instance</div>
<div body>
<div [formGroup]="serverForm" class="flex flex-col md:flex-row gap-4">
<input
id="serverUrl"
type="text"
pInputText
placeholder="mastodon.social"
[formControlName]="'instance'" (keydown.enter)="registerApplication()"
>
<button *ngIf="(loggedIn$ | async) === true"
pButton
type="button"
[disabled]="(instanceName$ | async) === undefined || !(applicationRegistered$ | async)"
(click)="logout()"
label="Logout"
></button>
<button pButton
type="button"
[disabled]="serverForm.invalid || (applicationRegistered$ | async) === true || (registeringApplication$ | async)"
[loading]="(registeringApplication$ | async)!"
(click)="registerApplication()"
label="Register Application"
></button>
<button
pButton
type="button"
[disabled]="(instanceName$ | async) === undefined || !(applicationRegistered$ | async) || (loggedIn$ | async)"
[loading]="(authorizingUser$ | async)!"
(click)="authorizeUser()"
label="Authorize User"
></button>
</div>
</div>
<div class="px-4 py-6 sm:px-0">
<div *ngIf="(loggedIn$ | async) && (instanceName$ | async) as instanceName">
<p>User is authorized with {{instanceName}}</p>
<p>Next up: Wait for <a routerLink="/sync">Sync</a> and manage</p>
</div>
</div>
</app-page>
<!--
<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'" (keydown.enter)="registerApplication()">
<button class="button" nbButton type="button"
[nbSpinner]="(registeringApplication$ | async)!"
[disabled]="serverForm.invalid || (applicationRegistered$ | async) === true || (registeringApplication$ | async)"
(click)="registerApplication()">Register Application
</button>
<button class="button" nbButton type="button"
[nbSpinner]="(authorizingUser$ | async)!"
[disabled]="(instanceName$ | async) === undefined || !(applicationRegistered$ | async) || (loggedIn$ | async)"
(click)="authorizeUser()">Authorize User
</button>
<button class="button" nbButton type="button"
[nbSpinner]="(authorizingUser$ | async)!"
[disabled]="(instanceName$ | async) === undefined || !(applicationRegistered$ | async)"
(click)="logout()">Logout
</button>
</div>
<div *ngIf="(loggedIn$ | async) && (instanceName$ | async) as instanceName">
<p>User is authorized with {{instanceName}}</p>
<p>Next up: Wait for <a routerLink="/sync">Sync</a> and manage</p>
</div>
</nb-card-body>
</nb-card>
-->

View File

@@ -19,6 +19,10 @@ import {
styleUrls: ['./authorize.component.scss']
})
export class AuthorizeComponent implements OnInit, OnDestroy {
steps = [
{label: 'Register Instance', description: 'Register the instance with the application', isValid: false},
{label: 'Authorize User', description: 'Authorize User with instance', isValid: false},
];
registeringApplication$: Observable<boolean>;
applicationRegistered$: Observable<boolean>;
authorizingUser$: Observable<boolean>;

View File

@@ -2,10 +2,13 @@ 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, 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";
import {MultiSelectModule} from "primeng/multiselect";
import {CheckboxModule} from "primeng/checkbox";
import {OverlayPanelModule} from "primeng/overlaypanel";
import {FormsModule} from "@angular/forms";
@NgModule({
@@ -18,13 +21,10 @@ import {MatrixComponent} from "./matrix/matrix.component";
CommonModule,
SharedModule,
FollowingsRoutingModule,
// Nebula
NbCheckboxModule,
NbListModule,
NbToggleModule,
NbBadgeModule,
NbPopoverModule,
NbUserModule,
MultiSelectModule,
CheckboxModule,
OverlayPanelModule,
FormsModule,
]
})
export class FollowingsModule {

View File

@@ -1,36 +1,43 @@
<nb-card>
<nb-card-header>
<h1>Followings</h1>
<div class="divider"></div>
<app-page>
<div title>Followings (List View)</div>
<div body>
<div class="pb-6">
<app-filters></app-filters>
</nb-card-header>
<nb-card-body>
<nb-list>
<nb-list-item *ngFor="let following of followings$ | async">
</div>
<div class="pt-6 grid gap-4 md:grid-cols lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
<div *ngFor="let following of followings$ | async" class="w-full overflow-y-visible overflow-x-hidden">
<app-account [account]="following">
<div body>
<div *ngIf="following.lists.length > 0">
<h4>Lists</h4>
<div *ngIf="following.lists.length > 0" class="pt-6">
<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>
<i class="pi pi-list pr-2 align-middle"></i>
<a [routerLink]="['/lists']" [queryParams]="{listId: list.id}" class="align-middle">{{list.title}}</a>
<i class="pl-2 pi pi-times-circle align-middle cursor-pointer text-red-600" (click)="removeAccountFromList(following.id, list.id)"></i>
</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 footer class="md:pt-6 flex flex-col space-y-6">
<p-multiSelect
[options]="(lists$ | async)!"
appendTo="body"
optionLabel="title"
optionValue="id"
#listSelect
></p-multiSelect>
<button
pButton
type="button"
class="p-button-sm"
(click)="addAccountToSelectedList(following.id, listSelect)"
label="Add to list(s)"
></button>
</div>
</app-account>
</nb-list-item>
</nb-list>
</nb-card-body>
</nb-card>
</div>
</div>
</div>
</app-page>

View File

@@ -1,22 +1,5 @@
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;
:host {
::ng-deep .p-multiselect {
width: 100%;
}
}

View File

@@ -3,6 +3,7 @@ import {Account, List} from 'projects/mastodon-api/src/public-api';
import {Observable} from "rxjs";
import {select, Store} from "@ngrx/store";
import {fromListViewPage, selectFilteredFollowingsWithLists, selectLists} from "../../shared/state/store";
import {MultiSelect} from "primeng/multiselect";
@Component({
selector: 'app-list',
@@ -11,15 +12,17 @@ import {fromListViewPage, selectFilteredFollowingsWithLists, selectLists} from "
})
export class ListComponent {
followings$: Observable<ReadonlyArray<Account>>;
lists$: Observable<ReadonlyArray<List>>;
lists$: Observable<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(fromListViewPage.addAccountToList({accountId, listId: select.value}));
addAccountToSelectedList(accountId: string, select: MultiSelect) {
select.value.forEach((listId: string) => {
this.store.dispatch(fromListViewPage.addAccountToList({accountId, listId}));
});
}
removeAccountFromList(accountId: string, listId: string) {

View File

@@ -1,28 +1,30 @@
<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-not-optimized-for-mobile-devices></app-not-optimized-for-mobile-devices>
<app-not-optimized-for-mobile-devices></app-not-optimized-for-mobile-devices>
<app-page>
<div title>Followings (Matrix View)</div>
<div body>
<div class="px-4 py-6 sm:px-0 flex flex-col divide-y">
<div class="pb-6">
<app-filters></app-filters>
</nb-card-header>
<nb-card-body>
<table>
</div>
<div class="pt-6 grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<table class="w-full text-sm text-left text-gray-500 dark:text-white">
<thead>
<tr>
<th style="vertical-align: bottom;">
Hover over a contact or click on it to see more info about the following
</th>
<th *ngFor="let list of lists$ | async" class="list-title">
<th class="min-w-[350px] sticky top-0 bg-gray-100 dark:bg-denim-dark"></th>
<th *ngFor="let list of lists$ | async" class="sticky top-0 bg-gray-100 dark:bg-denim-dark whitespace-nowrap h-[140px] align-bottom min-w-[50px]">
<div class="w-[30px] [rotate:280deg] [translate: 25px, 51px] ">
<span class="pl-4 pb-0">
{{list.title}}
</span>
</div>
</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let account of followings$ | async">
<th>
<ng-template #templateRef>
<div style="padding: 20px;">
<th class="border-solid border-0 border-gray-300 border-b">
<p-overlayPanel #info [dismissable]="true" [showCloseIcon]="true">
<ng-template pTemplate="">
<p [innerHTML]="account.note"></p>
<h4 *ngIf="account.fields.length > 0">Custom fields</h4>
<div class="table">
@@ -31,35 +33,23 @@
<div class="column" [innerHTML]="field.value"></div>
</div>
</div>
</div>
</ng-template>
<span (click)="openMoreInfo(account)"
[nbPopover]="templateRef"
nbPopoverTrigger="hover"
nbPopoverPlacement="bottom"
>
{{account.displayName}} <span class="username">@{{account.acct}}</span>
</span>
</p-overlayPanel>
<span class="font-bold">{{account.displayName}}</span> <span class="text-gray-400 pl-2">@{{account.acct}}</span> <i class="pl-2 pi pi-info-circle"
(click)="info.toggle($event)"></i>
</th>
<td *ngFor="let list of lists$ | async">
<nb-checkbox style="cursor: pointer;"
<td *ngFor="let list of lists$ | async" class="border-solid border-0 border-gray-300 border-b border-l text-center h-[50px]">
<input
type="checkbox"
[checked]="isChecked(account.lists, list.id)"
(checkedChange)="onCheckedChange($event, account.id, list.id)">
</nb-checkbox>
(change)="onCheckedChange($event, account.id, list.id)"
/>
</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>
</div>
</app-page>

View File

@@ -1,44 +0,0 @@
span.username {
font-weight: 100;
margin-left: 10px;
color: darkgray;
}
table {
display: block;
height: max(600px, calc(100vh - 300px));
width: 100%;
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;
}
}

View File

@@ -1,7 +1,6 @@
import {Component, TemplateRef, ViewChild} from '@angular/core';
import {Observable} from "rxjs";
import {select, Store} from "@ngrx/store";
import {NbDialogService} from "@nebular/theme";
import {fromMatrixViewPage, selectFilteredFollowingsWithLists, selectLists} from "../../shared/state/store";
import {Account, List} from 'projects/mastodon-api/src/public-api';
@@ -16,22 +15,23 @@ export class MatrixComponent {
lists$: Observable<ReadonlyArray<List>>;
selectedAccount: Account | undefined;
constructor(private store: Store, private dialogService: NbDialogService) {
constructor(private store: Store) {
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!);
// TODO
// 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) {
onCheckedChange($event: any, accountId: string, listId: string) {
if ($event.currentTarget.checked) {
this.store.dispatch(fromMatrixViewPage.addAccountToList({accountId, listId}));
} else {
this.store.dispatch(fromMatrixViewPage.removeAccountFromList({accountId, listId}));

View File

@@ -1,58 +1,78 @@
<nb-card>
<nb-card-header style="position: relative;">
<h1>Table View for Followings</h1>
<div class="divider"></div>
<app-not-optimized-for-mobile-devices></app-not-optimized-for-mobile-devices>
<app-not-optimized-for-mobile-devices></app-not-optimized-for-mobile-devices>
<app-page>
<div title>Followings (Table View)</div>
<div body>
<div class="px-4 py-6 sm:px-0 flex flex-col divide-y">
<div class="pb-6">
<app-filters></app-filters>
</nb-card-header>
<nb-card-body>
<table>
</div>
<div class="pt-6">
<p-table [value]="(rows$ | async)!" styleClass="p-datatable-striped">
<ng-template pTemplate="header">
<tr>
<th>Username</th>
<th>Notes</th>
<th>Fields</th>
<th>Lists</th>
<th>
Username
</th>
<th>
Notes
</th>
<th>
Fields
</th>
<th>
Lists
</th>
<th>Actions</th>
</tr>
<tr *ngFor="let row of rows$ |async">
<td class="col small">
<a [href]="row.url">
<nb-user
size="medium"
shape="semi-round"
[name]="row.displayName"
[title]="row.username"
[picture]="row.avatar"
>
</nb-user>
</ng-template>
<ng-template pTemplate="body" let-row>
<tr>
<td class="small">
<div class="flex items-center space-x-4 pb-6">
<img class="w-10 h-10 rounded-full" [src]="row.avatar" alt="">
<div class="font-medium">
<div>{{row.displayName}}</div>
<div class="text-sm text-gray-500 dark:text-gray-400">
<a [href]="row.url" rel="noreferrer" target="_blank">
{{row.username}}
</a>
</td>
<td class="col large">
<span [innerHTML]="row.note"></span>
</td>
<td class="col large">
<span [innerHTML]="row.fields"></span>
</td>
<td class="col medium">
<div *ngFor="let list of row.lists">
{{list.title}}
<nb-icon icon="person-remove-outline" (click)="removeAccountFromList(row.id, list.id)" style="cursor: pointer;">
Remove Account from list
</nb-icon>
</div>
</div>
</div>
</td>
<td class="col medium">
<div class="actions">
<select class="list-select" #listSelect>
<option *ngFor="let list of lists$ | async" [value]="list.id">{{list.title}}</option>
</select>
<nb-icon icon="person-add-outline" (click)="addAccountToSelectedList(row.id, listSelect.value)" style="cursor: pointer;">
Add Account to list
</nb-icon>
<td class="medium">
<span [innerHTML]="row.note"></span>
</td>
<td class="medium">
<span [innerHTML]="row.fields"></span>
</td>
<td class="medium">
<div *ngFor="let list of row.lists">
<a [routerLink]="['/lists']" [queryParams]="{listId: list.id}" class="align-middle">{{list.title}}</a>
<i class="pl-2 pi pi-times-circle align-middle cursor-pointer text-red-600" (click)="removeAccountFromList(row.id, list.id)"></i>
</div>
</td>
<td class="large">
<div class="flex flex-col space-y-4 md:flex-row md:space-y-0 md:space-x-4">
<p-multiSelect
[options]="(lists$ | async)!"
optionLabel="title"
optionValue="id"
#listSelect
></p-multiSelect>
<button
pButton
type="button"
(click)="addAccountToSelectedList(row.id, listSelect)"
label="Add to list(s)"
></button>
</div>
</td>
</tr>
</table>
</nb-card-body>
</nb-card>
</ng-template>
</p-table>
</div>
</div>
</div>
</app-page>

View File

@@ -1,40 +1,13 @@
table {
display: block;
height: max(600px, calc(100vh - 300px));
::ng-deep .p-datatable {
width: 100%;
overflow-x: auto;
overflow-y: auto;
border: none;
overflow-x: auto;
th {
border: 1pt solid #101426;
td.medium {
max-width: calc(100vw / 6);
}
td {
border: 1pt solid #101426;
}
td.col {
&.small {
min-width: 180px;
}
&.medium {
min-width: 300px;
}
&.large {
min-width: 400px;
}
max-width: calc(100vw / 4);
overflow: hidden;
text-overflow: ellipsis;
}
}
div.actions {
display: flex;
flex-direction: row;
select {
margin-right: 5px;
td.large {
min-width: 320px !important;
}
}

View File

@@ -1,8 +1,10 @@
import {Component} from '@angular/core';
import {map, Observable} from "rxjs";
import {select, Store} from "@ngrx/store";
import {fromTableViewPage, selectFilteredFollowingsWithLists, selectLists} from "../../shared/state/store";
import {fromListViewPage, fromTableViewPage, selectFilteredFollowingsWithLists, selectLists} from "../../shared/state/store";
import {Account, List} from 'projects/mastodon-api/src/public-api';
import {MultiSelect} from "primeng/multiselect";
import {FilterService} from "primeng/api";
interface DataGridRow {
id: string;
@@ -22,9 +24,23 @@ interface DataGridRow {
})
export class TableComponent {
rows$: Observable<DataGridRow[]>;
lists$: Observable<ReadonlyArray<List>>;
lists$: Observable<List[]>;
filterByListName = 'filter-by-lists';
constructor(private store: Store, private filterService: FilterService) {
this.filterService.register(this.filterByListName, (value: List[], filter: string[]): boolean => {
console.log(filter);
if (!filter || filter.length === 0) {
return true;
}
if (filter && filter.length === 1 && filter[0] === '0') {
console.log('here');
return value.length === 0;
}
return value.some(list => filter.includes(list.id));
});
constructor(private store: Store) {
this.rows$ = this.store
.pipe(
select(selectFilteredFollowingsWithLists),
@@ -46,10 +62,13 @@ export class TableComponent {
this.lists$ = this.store.pipe(select(selectLists));
}
addAccountToSelectedList(accountId: string, listId: string) {
this.store.dispatch(fromTableViewPage.addAccountToList({accountId, listId}));
addAccountToSelectedList(accountId: string, select: MultiSelect) {
select.value.forEach((listId: string) => {
this.store.dispatch(fromListViewPage.addAccountToList({accountId, listId}));
});
}
removeAccountFromList(accountId: string, listId: string) {
this.store.dispatch(fromTableViewPage.removeAccountFromList({accountId, listId}));
}

View File

@@ -1,15 +1,12 @@
<nb-card>
<nb-card-header>
<h1>Welcome to Mastolists</h1>
</nb-card-header>
<nb-card-body>
<app-page>
<div title>Welcome to Mastolists</div>
<div body>
<p>Organize your Followings into lists.</p>
<ul>
<li><a [routerLink]="['/authorize']">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']">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>
</div>
</app-page>

View File

@@ -0,0 +1,16 @@
<footer class="p-4 bg-denim text-white dark:bg-denim-dark dark:text-white md:flex md:items-center md:justify-between md:p-6">
<div class="text-sm sm:text-left flex flex-col">
<a href="https://wwww.novaloop.ch/" class="hover:underline">Novaloop Ltd</a>
Niederdorfstrasse 88<br>
8001 Zurich<br>
<a href="tel:+41 44 500 54 60">+41 44 500 54 60</a>
<a href="mailto:mail@novaloop.ch">mail@novaloop.ch</a><br/>
</div>
<div class="text-sm sm:text-right flex flex-col">
<a href="https://novaloop.social/@magbeat" target="_blank" rel="noreferrer">@magbeat@novaloop.social</a>
<a href="https://novaloop.social/@snowping" target="_blank" rel="noreferrer">@snowping@novaloop.social</a>
<a href="https://novaloop.social/@langhard" target="_blank" rel="noreferrer">@langhard@novaloop.social</a>
<a href="https://git.novaloop.ch/novaloop-oss/mastodon-apps" target="_blank" rel="noreferrer">Source Code (v{{version}})</a>
<a href="https://git.novaloop.ch/novaloop-oss/mastodon-apps/issues" target="_blank" rel="noreferrer">Issues</a>
</div>
</footer>

View File

@@ -0,0 +1,7 @@
a {
@apply text-olympic-light;
}
a:hover {
@apply text-olympic;
}

View File

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

View File

@@ -0,0 +1,11 @@
import {Component} from '@angular/core';
import packageJson from "../../../../../../package.json";
@Component({
selector: 'app-footer',
templateUrl: './footer.component.html',
styleUrls: ['./footer.component.scss']
})
export class FooterComponent {
version = packageJson.version;
}

View File

@@ -0,0 +1,111 @@
<nav class="bg-denim dark:bg-denim-dark">
<div class="mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex h-16 items-center justify-between">
<div class="flex items-center">
<div class="flex-shrink-0">
<a [routerLink]="'/home'">
<img class="h-8 w-8" src="assets/images/novaloop.png" alt="Novaloop AG">
</a>
</div>
<div class="flex-shrink-0 pl-4">
<a [routerLink]="'/home'">
<h1 class="text-white text-xl">Mastolists</h1>
</a>
</div>
</div>
<div class="hidden md:block">
<div class="ml-4 flex items-center md:ml-6">
<div class="hidden md:block">
<div class="ml-10 flex items-baseline space-x-4">
<a href="#" *ngFor="let item of navigationItems"
[routerLink]="item.link"
[title]="item.title"
class="text-gray-300 hover:bg-denim-darker hover:text-white px-3 py-2 rounded-md text-sm font-medium"
[routerLinkActive]="'bg-denim-darker dark:bg-gray-700 text-white'"
>
{{ item.title }}
</a>
<!--
<a href="#" class="bg-gray-900 text-white px-3 py-2 rounded-md text-sm font-medium" aria-current="page">Dashboard</a>
<a href="#" class="text-gray-300 hover:bg-gray-700 hover:text-white px-3 py-2 rounded-md text-sm font-medium">Team</a>
-->
</div>
</div>
<div class="relative ml-3 flex flex-row space-x-4">
<div>
<a href="#" [routerLink]="'/authorize'">
<img class="h-8 w-8 rounded-full"
*ngIf="currentAccount$ | async as account; else noAccount"
[src]="account.avatar"
alt=""
>
<ng-template #noAccount>
<i class="pi pi-user text-white pt-2"></i>
</ng-template>
</a>
</div>
<div>
<p-inputSwitch class="input-switch" [(ngModel)]="isDarkThemeEnabled" (onChange)="toggleTheme($event)"></p-inputSwitch>
</div>
</div>
</div>
</div>
<div class="-mr-2 flex md:hidden">
<!-- Mobile menu button -->
<button type="button"
class="inline-flex items-center justify-center rounded-md bg-gray-800 p-2 text-gray-400 hover:bg-gray-700 hover:text-white focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-0 focus:ring-offset-gray-800"
(click)="navOpen = !navOpen"
aria-controls="mobile-menu" aria-expanded="false">
<span class="sr-only">Open main menu</span>
<!--
Heroicon name: outline/bars-3
Menu open: "hidden", Menu closed: "block"
-->
<svg class="block h-6 w-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"/>
</svg>
<!--
Heroicon name: outline/x-mark
Menu open: "block", Menu closed: "hidden"
-->
<svg class="hidden h-6 w-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</div>
</div>
<!-- Mobile menu, show/hide based on menu state. -->
<div *ngIf="navOpen" class="md:hidden" id="mobile-menu">
<div class="space-y-1 px-2 pt-2 pb-3 sm:px-3 flex flex-col">
<a href="#" *ngFor="let item of navigationItems"
[routerLink]="item.link"
[title]="item.title"
(click)="navOpen = false"
class="text-gray-300 hover:bg-denim-darker hover:text-white px-3 py-2 rounded-md text-sm font-medium"
[routerLinkActive]="'bg-denim-darker text-white'"
>
{{ item.title }}
</a>
</div>
<div class="border-t border-gray-700 pt-4 pb-3" *ngIf="currentAccount$ | async as account">
<div class="flex items-center px-5">
<div class="flex-shrink-0">
<img class="h-10 w-10 rounded-full" [src]="account.avatar" alt="">
</div>
<div class="ml-3">
<div class="text-base font-medium leading-none text-white">{{account.displayName}}</div>
<div class="text-sm font-medium leading-none text-gray-400">@{{account.acct}}</div>
</div>
</div>
</div>
<div class="ml-3 pl-2 pb-4">
<p-inputSwitch class="input-switch" [(ngModel)]="isDarkThemeEnabled" (onChange)="toggleTheme($event)"></p-inputSwitch>
</div>
</div>
</nav>

View File

@@ -0,0 +1,8 @@
:host ::ng-deep .p-inputswitch-slider:before {
content: "\263C" !important;
}
:host ::ng-deep .p-inputswitch.p-inputswitch-checked .p-inputswitch-slider:before {
color: black;
content: "\263D" !important;
}

View File

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

View File

@@ -0,0 +1,36 @@
import {Component} from '@angular/core';
import {select, Store} from "@ngrx/store";
import {Account} from 'projects/mastodon-api/src/public-api';
import {Observable} from "rxjs";
import {selectCurrentAccount} from "../../shared/state/store";
import {ThemeService} from "../../shared/services/theme.service";
import {InputSwitchOnChangeEvent} from "primeng/inputswitch";
@Component({
selector: 'app-header',
templateUrl: './header.component.html',
styleUrls: ['./header.component.scss']
})
export class HeaderComponent {
navOpen = false;
navigationItems = [
{title: 'Authorize', link: '/authorize'},
{title: 'Stats', link: '/sync'},
{title: 'Edit Lists', link: '/lists'},
{title: 'List view', link: '/followings/list'},
{title: 'Matrix View', link: '/followings/matrix'},
{title: 'Table View', link: '/followings/table'},
];
currentAccount$: Observable<Account | undefined>;
isDarkThemeEnabled: boolean = this.themeService.isDarkThemeEnabled();
constructor(private store: Store, private themeService: ThemeService) {
this.currentAccount$ = this.store.pipe(select(selectCurrentAccount));
}
toggleTheme(event: InputSwitchOnChangeEvent) {
this.themeService.setTheme(event.checked ? 'dark' : 'light')
}
}

View File

@@ -3,8 +3,8 @@ 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";
import {FormsModule, ReactiveFormsModule} from "@angular/forms";
import {RippleModule} from "primeng/ripple";
@NgModule({
@@ -16,8 +16,8 @@ import {ReactiveFormsModule} from "@angular/forms";
SharedModule,
ListsRoutingModule,
ReactiveFormsModule,
// Nebular
NbSpinnerModule,
FormsModule,
RippleModule,
]
})
export class ListsModule {

View File

@@ -1,31 +1,83 @@
<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>
<app-page>
<div title>Edit Lists</div>
<div body>
<div [formGroup]="newListForm" class="flex flex-col md:flex-row gap-6">
<input
id="title"
type="text"
pInputText
[formControlName]="'title'"
>
<button
pButton
type="button"
[loading]="creatingList"
[disabled]="creatingList"
(click)="createList()"
label="Create List"
></button>
<button pButton
type="button"
class="p-button-help"
[loading]="(loading$ | async)!"
[disabled]="(loading$ | async)!"
(click)="loadLists()"
label="Sync Lists"
></button>
</div>
</nb-card-header>
<nb-card-body>
<table *ngIf="(instanceName$ | async) as instanceName">
<div class="pt-6">
<p-table [value]="(lists$ | async)!"
styleClass="p-datatable-striped"
dataKey="id"
editMode="row"
(sortFunction)="customSort($event)"
[customSort]="true"
>
<ng-template pTemplate="header">
<tr>
<th>Id</th>
<th>Title</th>
<th pSortableColumn="id" style="width: 80px">
Id
<p-sortIcon field="id"></p-sortIcon>
</th>
<th pSortableColumn="title">
Title
<p-sortIcon field="title"></p-sortIcon>
</th>
<th></th>
</tr>
<tr *ngFor="let list of lists$ | async">
</ng-template>
<ng-template pTemplate="body" let-list let-editing="editing">
<tr [pEditableRow]="list">
<td>{{list.id}}</td>
<td>
<p-cellEditor>
<ng-template pTemplate="input">
<input pInputText type="text" [(ngModel)]="editModels[list.id].title" #titleInput>
</ng-template>
<ng-template pTemplate="output">
{{list.title}}
</ng-template>
</p-cellEditor>
</td>
<td>
<div class="flex align-items-center justify-content-center gap-2">
<button *ngIf="!editing" pButton pRipple type="button" pInitEditableRow icon="pi pi-pencil" (click)="onRowEditInit(list)"
class="p-button-rounded p-button-text"></button>
<button *ngIf="editing" pButton pRipple type="button" pSaveEditableRow icon="pi pi-check" (click)="onRowEditSave(list)"
class="p-button-rounded p-button-text p-button-success mr-2"></button>
<button *ngIf="editing" pButton pRipple type="button" pCancelEditableRow icon="pi pi-times" (click)="onRowEditCancel(list)"
class="p-button-rounded p-button-text p-button-danger"></button>
<div *ngIf="instanceName$ | async as instanceName" class="pt-4">
<a [href]="'https://' + instanceName + '/lists/' + list.id" target="_blank" rel="noopener">
{{list.id}}
<i class="pi pi-external-link"></i>
</a>
</td>
<td>
<input nbInput id="serverUrl" type="text" [value]="list.title" (change)="listNameChanged(list.id, $event)">
</div>
</div>
</td>
</tr>
</table>
</nb-card-body>
</nb-card>
</ng-template>
</p-table>
</div>
</div>
</app-page>

View File

@@ -2,8 +2,9 @@ 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 {fromListsPage, selectCurrentInstance, selectLists} from "../../shared/state/store";
import {fromListsPage, fromSyncPage, selectCurrentInstance, selectLists, selectLoading} from "../../shared/state/store";
import {FormControl, FormGroup} from "@angular/forms";
import {SortEvent} from "primeng/api";
@Component({
selector: 'app-lists',
@@ -11,10 +12,11 @@ import {FormControl, FormGroup} from "@angular/forms";
styleUrls: ['./lists.component.scss']
})
export class ListsComponent {
lists$: Observable<ReadonlyArray<List>>;
loading$: Observable<boolean>;
lists$: Observable<List[]>;
instanceName$: Observable<string | undefined>;
creatingList: boolean = false;
editModels: { [s: string]: List; } = {}
newListForm = new FormGroup({
title: new FormControl(''),
@@ -23,12 +25,39 @@ export class ListsComponent {
constructor(private store: Store) {
this.instanceName$ = this.store.pipe(select(selectCurrentInstance));
this.lists$ = this.store.pipe(select(selectLists));
this.loading$ = this.store.pipe(select(selectLoading));
}
listNameChanged(id: string, event: Event) {
const target = event.target as HTMLInputElement;
const newTitle = target.value;
this.store.dispatch(fromListsPage.updateList({listId: id, newTitle}));
customSort(event: SortEvent) {
console.log(event);
if (event.field == 'id') {
return event.data!.sort((a, b) => {
if (event.order == 1) {
return parseInt(a['id']) >= parseInt(b['id']) ? 1 : -1;
}
return parseInt(a['id']) < parseInt(b['id']) ? 1 : -1;
});
}
return event.data!.sort((a, b) => {
if (event.order == 1) {
return a[event.field!] >= b[event.field!] ? 1 : -1;
}
return a[event.field!] < b[event.field!] ? 1 : -1;
});
}
onRowEditInit(list: List) {
this.editModels[list.id] = {...list};
}
onRowEditSave(list: List) {
const newTitle = this.editModels[list.id].title;
this.store.dispatch(fromListsPage.updateList({listId: list.id, newTitle}));
delete this.editModels[list.id];
}
onRowEditCancel(list: List) {
delete this.editModels[list.id];
}
createList() {
@@ -38,4 +67,8 @@ export class ListsComponent {
this.newListForm.reset();
}
}
loadLists() {
this.store.dispatch(fromSyncPage.loadLists());
}
}

View File

@@ -1,29 +1,30 @@
<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.acct"
[picture]="account.avatar"
>
</nb-user>
<div class="p-6 bg-white dark:bg-denim-darker rounded-lg h-full">
<div class="flex flex-col h-full divide-y justify-between">
<div class="flex flex-col md:flex-row md:items-center md:space-x-4 md:pb-6">
<img class="w-10 h-10 rounded-full" [src]="account.avatar" alt="">
<div class="font-medium">
<div>{{account.displayName}}</div>
<div class="text-sm text-gray-500 dark:text-white">
<a [href]="account.url" rel="noreferrer" target="_blank">
@{{account.acct}}
</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>
</div>
<div class="flex-grow pb-6">
<div *ngIf="account.note" class="pt-6">
<span [innerHTML]="account.note"></span>
</div>
<div *ngIf="account.fields.length > 0" class="flex flex-col md:pt-6 space-y-4">
<div class="flex flex-col md:flex-row md:space-x-4" *ngFor="let field of account.fields">
<div class="min-w-[80px]">{{ field.name }}</div>
<div [innerHTML]="field.value"></div>
</div>
</div>
<ng-content select="'[body]'"></ng-content>
</nb-card-body>
<nb-card-footer>
</div>
<div>
<ng-content select="'[footer]'"></ng-content>
</nb-card-footer>
</nb-card>
</div>
</div>
</div>

View File

@@ -1,25 +1,5 @@
:host {
width: 100%;
height: 100%;
}
.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;
}
}
}

View File

@@ -1,3 +1,52 @@
<div class="form flex flex-row space-x-4">
<form [formGroup]="filtersForm" class="w-full">
<div class="full flex flex-col space-y-4 md:flex-row md:space-x-4 md:space-y-0">
<input
class="w-full md:w-[200px]"
id="text"
type="text"
pInputText
placeholder="@username or free text"
[formControlName]="'text'"
>
<p-multiSelect
[options]="(lists$ | async)!"
optionLabel="title"
optionValue="id"
placeholder="Multiple Select"
[formControlName]="'lists'"
></p-multiSelect>
<div class="flex flex-row space-x-2 pt-3">
<p-inputSwitch [formControlName]="'unlisted'"></p-inputSwitch>
<span>Unlisted only</span>
</div>
<button pButton
type="button"
(click)="clearFilter()"
label="Clear Filter"
></button>
</div>
</form>
</div>
<!--
<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 class="container">
<div class="form">
<form [formGroup]="filtersForm">
@@ -18,3 +67,4 @@
<button nbButton (click)="clearFilter()">Clear Filter</button>
</div>
</div>
-->

View File

@@ -1,46 +1,6 @@
.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;
}
}
@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;
}
::ng-deep .p-multiselect {
width: 100%;
@screen md {
width: 200px;
}
}

View File

@@ -20,30 +20,50 @@ export class FiltersComponent implements OnDestroy {
filtersForm = new FormGroup({
text: new FormControl(),
lists: new FormControl([] as string[]),
unlisted: new FormControl(false),
});
currentFilter: { fullText: string | undefined; lists: string[]; unlisted: boolean; } = {fullText: undefined, lists: [], unlisted: false};
lists$: Observable<ReadonlyArray<List>>;
lists$: Observable<List[]>;
filters$: Observable<FiltersForForm>;
formSubscription: Subscription;
filterSubscription: Subscription;
constructor(private store: Store) {
this.lists$ = this.store.pipe(select(selectLists));
this.filters$ = this.store.pipe(
select(selectFiltersForForm),
tap(filters => {
this.currentFilter = {...filters, lists: [...filters.lists]};
this.filtersForm.patchValue({
text: filters.fullText,
lists: filters.lists as string[],
unlisted: filters.unlisted,
}, {emitEvent: false, onlySelf: true});
}),
);
this.filterSubscription = this.filters$.subscribe();
this.formSubscription = this.filtersForm.valueChanges
.pipe(debounceTime(500))
.subscribe(value => {
if (value['unlisted'] && !this.currentFilter.unlisted) {
if (this.currentFilter.lists && this.currentFilter.lists.length > 0) {
this.filtersForm.patchValue({
lists: [],
}, {emitEvent: false, onlySelf: true});
value['lists'] = [];
this.store.dispatch(fromFilters.setLists({lists: []}));
}
this.store.dispatch(fromFilters.setUnlisted({unlisted: true}));
} else if (this.currentFilter.unlisted) {
this.store.dispatch(fromFilters.setUnlisted({unlisted: false}));
}
if (value['lists'] && value['lists'].length > 0) {
this.store.dispatch(fromFilters.setLists({lists: value.lists}));
if (this.currentFilter.unlisted) {
this.store.dispatch(fromFilters.setUnlisted({unlisted: false}));
}
}
if (!value['lists'] || value['lists'].length === 0) {
this.store.dispatch(fromFilters.setLists({lists: []}));
}
@@ -63,22 +83,20 @@ export class FiltersComponent implements OnDestroy {
this.store.dispatch(fromFilters.setFreeText({freeText: []}));
}
} else {
if (this.currentFilter.fullText && this.currentFilter.fullText.length > 0) {
this.store.dispatch(fromFilters.setUsername({username: ''}));
this.store.dispatch(fromFilters.setFreeText({freeText: []}));
}
}
});
}
ngOnDestroy(): void {
this.formSubscription.unsubscribe();
this.filterSubscription.unsubscribe();
}
clearFilter() {
this.store.dispatch(fromFilters.clearFilters());
}
toggleUnlisted($event: boolean) {
this.store.dispatch(fromFilters.setUnlisted({unlisted: $event}));
}
}

View File

@@ -1,4 +1,5 @@
<div class="only-visible-on-mobile">
<div class="only-visible-on-mobile m-4 p-4 rounded text-xl bg-gray-100 dark:bg-denim-dark">
<p>This view is not optimized for mobile devices</p>
<p>For a better experience on mobile visit the <a [routerLink]="['/followings', 'list']">list view</a></p>
</div>

View File

@@ -1,9 +1,9 @@
.only-visible-on-mobile {
display: none;
display: block;
}
@media (max-width: 573px) {
@screen lg {
.only-visible-on-mobile {
display: block;
display: none;
}
}

View File

@@ -0,0 +1,14 @@
<div class="m-4 md:m-10 rounded bg-gray-100 dark:bg-denim-dark">
<header>
<div class="mx-auto p-4 sm:p-6 lg:p-8">
<h1 class="text-3xl font-bold tracking-tight text-gray-900 dark:text-white">
<ng-content select="'[title]'"></ng-content>
</h1>
</div>
</header>
<main>
<div class="mx-auto p-4 sm:p-6 lg:p-8">
<ng-content select="'[body]'"></ng-content>
</div>
</main>
</div>

View File

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

View File

@@ -0,0 +1,10 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-page',
templateUrl: './page.component.html',
styleUrls: ['./page.component.scss']
})
export class PageComponent {
}

View File

@@ -0,0 +1,19 @@
import {Injectable} from "@angular/core";
import {MessageService} from 'primeng/api';
@Injectable({
providedIn: 'root'
})
export class NlNotificationService {
constructor(private messageService: MessageService) {
}
danger(content: string, style: "warning" | "error" = "error") {
this.messageService.add({detail: content, severity: style})
}
success(content: string, style: "success" | "info" = "info") {
this.messageService.add({detail: content, severity: style})
}
}

View File

@@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { ThemeService } from './theme.service';
describe('ThemeService', () => {
let service: ThemeService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(ThemeService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@@ -0,0 +1,28 @@
import {Inject, Injectable} from '@angular/core';
import {DOCUMENT} from "@angular/common";
@Injectable({
providedIn: 'root'
})
export class ThemeService {
private currentTheme = 'light';
constructor(@Inject(DOCUMENT) private document: Document) {
}
setTheme(theme: string) {
let themeLink = this.document.getElementById('app-theme') as HTMLLinkElement;
if (this.currentTheme != theme) {
this.currentTheme = theme;
if (themeLink) {
themeLink.href = this.currentTheme + '.css';
}
}
}
isDarkThemeEnabled() {
return this.currentTheme == 'dark';
}
}

View File

@@ -1,21 +1,18 @@
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";
import {NotOptimizedForMobileDevicesComponent} from './components/not-optimized-for-mobile-devices/not-optimized-for-mobile-devices.component';
import {RouterLink} from "@angular/router";
import {ToastModule} from 'primeng/toast';
import {MessageService} from "primeng/api";
import {ButtonModule} from "primeng/button";
import {InputTextModule} from "primeng/inputtext";
import {TableModule} from "primeng/table";
import {MultiSelectModule} from "primeng/multiselect";
import {InputSwitchModule} from "primeng/inputswitch";
import {PageComponent} from './components/page/page.component';
@NgModule({
@@ -23,36 +20,32 @@ import {RouterLink} from "@angular/router";
AccountComponent,
FiltersComponent,
NotOptimizedForMobileDevicesComponent,
PageComponent,
],
providers: [
MessageService
],
providers: [],
imports: [
CommonModule,
ReactiveFormsModule,
// Nebula import only
NbInputModule,
NbButtonModule,
NbSpinnerModule,
NbSelectModule,
NbToggleModule,
// Nebula
NbCardModule,
NbUserModule,
NbActionsModule,
NbIconModule,
NbButtonModule,
NbInputModule,
RouterLink,
ToastModule,
//PrimeNG
ButtonModule,
InputTextModule,
TableModule,
MultiSelectModule,
InputSwitchModule,
],
exports: [
AccountComponent,
FiltersComponent,
// Nebula
NbCardModule,
NbActionsModule,
NbIconModule,
NbButtonModule,
NbInputModule,
NotOptimizedForMobileDevicesComponent,
PageComponent,
//PrimeNG
ButtonModule,
InputTextModule,
TableModule,
]
})
export class SharedModule {

View File

@@ -1,4 +1,5 @@
import {createActionGroup, emptyProps, props} from "@ngrx/store";
import {Account} from "projects/mastodon-api/src/public-api";
export const fromAuthorize = createActionGroup({
source: 'Authorize',
@@ -9,7 +10,7 @@ export const fromAuthorize = createActionGroup({
'Register Application Error': props<{ error: any }>(),
'Authorize User': props<{ instanceName: string, clientId: string, redirectUrl: string }>(),
'Get Access Token': props<{ code: string, instanceName: string, clientId: string, clientSecret: string, redirectUrl: string }>(),
'Get Access Token Success': props<{ accessToken: string, accountId: string }>(),
'Get Access Token Success': props<{ accessToken: string, account: Account }>(),
'Get Access Token Error': emptyProps(),
'Logout': emptyProps(),
}

View File

@@ -1,10 +1,12 @@
import {Account} from "projects/mastodon-api/src/public-api";
export interface AuthenticationState {
registeringApplication: boolean;
applicationRegistered: boolean;
authorizingUser: boolean;
instanceName?: string;
accessToken?: string;
accountId?: string;
account?: Account;
id?: string;
appName?: string;
website?: string;

View File

@@ -1,11 +1,11 @@
import {Injectable} from "@angular/core";
import {Actions, createEffect, ofType} from "@ngrx/effects";
import {catchError, exhaustMap, map, of, switchMap, tap, zip} from "rxjs";
import {NbToastrService} from "@nebular/theme";
import {List} from "projects/mastodon-api/src/public-api";
import {fromApplication, fromListsPage, fromListViewPage, fromMastodonApi, fromMatrixViewPage, fromSyncPage, fromTableViewPage} from "../actions";
import {ListService} from "../../../services/list.service";
import {AccountService} from "../../../services/account.service";
import {NlNotificationService} from "../../../services/nl-notification.service";
@Injectable()
export class ApplicationStateEffects {
@@ -36,13 +36,13 @@ export class ApplicationStateEffects {
updateListSuccess$ = createEffect(() =>
this.actions$.pipe(
ofType(fromMastodonApi.updateListSuccess),
map(() => this.toastService.success('List updated successfully', 'Success')),
map(() => this.notificationService.success('List updated successfully', 'success')),
), {dispatch: false}
);
updateListError$ = createEffect(() =>
this.actions$.pipe(
ofType(fromMastodonApi.updateListError),
map(() => this.toastService.danger('Could not update the list', 'Error')),
map(() => this.notificationService.danger('Could not update the list', 'error')),
), {dispatch: false}
);
@@ -62,13 +62,13 @@ export class ApplicationStateEffects {
createListSuccess$ = createEffect(() =>
this.actions$.pipe(
ofType(fromMastodonApi.createListSuccess),
map(() => this.toastService.success('List created successfully', 'Success')),
map(() => this.notificationService.success('List created successfully', 'success')),
), {dispatch: false}
);
createListError$ = createEffect(() =>
this.actions$.pipe(
ofType(fromMastodonApi.createListError),
map(() => this.toastService.danger('Could not create the list', 'Error')),
map(() => this.notificationService.danger('Could not create the list', 'error')),
), {dispatch: false}
);
@@ -76,7 +76,7 @@ export class ApplicationStateEffects {
this.actions$.pipe(
ofType(fromMastodonApi.listsLoadedSuccess),
switchMap((action) => {
this.toastService.success('Lists loaded successfully', 'Success');
this.notificationService.success('Lists loaded successfully', 'success');
if (action.lists.length === 0) return of(fromMastodonApi.accountIdsForListsLoadedSuccess({mappings: {}}));
const mappingsArr = action.lists.map((list) => this.listService.loadAccountsIdsForList(list.id));
return zip(mappingsArr)
@@ -110,14 +110,14 @@ export class ApplicationStateEffects {
loadFollowingsSuccess$ = createEffect(() =>
this.actions$.pipe(
ofType(fromMastodonApi.followingsLoadedSuccess),
tap(() => this.toastService.success('Followings loaded successfully', 'Success')),
tap(() => this.notificationService.success('Followings loaded successfully', 'success')),
)
, {dispatch: false});
loadFollowingsError$ = createEffect(() =>
this.actions$.pipe(
ofType(fromMastodonApi.followingsLoadedError),
tap(() => this.toastService.danger('Followings could not be loaded', 'Error')),
tap(() => this.notificationService.danger('Followings could not be loaded', 'error')),
)
, {dispatch: false});
@@ -135,14 +135,14 @@ export class ApplicationStateEffects {
addAccountToListSuccess$ = createEffect(() =>
this.actions$.pipe(
ofType(fromMastodonApi.addAccountToListSuccess),
map(() => this.toastService.success('Account added to list', 'Success')),
map(() => this.notificationService.success('Account added to list', 'success')),
), {dispatch: false}
);
addCountsToListError$ = createEffect(() =>
this.actions$.pipe(
ofType(fromMastodonApi.addAccountToListError),
map(() => this.toastService.danger('Could not add account to list, please reload', 'Error')),
map(() => this.notificationService.danger('Could not add account to list, please reload', 'error')),
), {dispatch: false}
);
@@ -159,14 +159,14 @@ export class ApplicationStateEffects {
removeAccountFromListSuccess$ = createEffect(() =>
this.actions$.pipe(
ofType(fromMastodonApi.removeAccountFromListSuccess),
map(() => this.toastService.success('Account removed from list', 'Success')),
map(() => this.notificationService.success('Account removed from list', 'success')),
), {dispatch: false}
);
removeAccountFromListError$ = createEffect(() =>
this.actions$.pipe(
ofType(fromMastodonApi.removeAccountFromListError),
map(() => this.toastService.danger('Could not remove account from list, please reload', 'Error')),
map(() => this.notificationService.danger('Could not remove account from list, please reload', 'error')),
), {dispatch: false}
);
@@ -174,7 +174,7 @@ export class ApplicationStateEffects {
private actions$: Actions,
private listService: ListService,
private accountService: AccountService,
private toastService: NbToastrService,
private notificationService: NlNotificationService,
) {
}
}

View File

@@ -2,10 +2,10 @@ import {Injectable} from "@angular/core";
import {Actions, createEffect, ofType} from "@ngrx/effects";
import {fromAuthorize} from "../actions";
import {catchError, exhaustMap, map, of, switchMap} from "rxjs";
import {NbToastrService} from "@nebular/theme";
import {MastodonApiAuthenticationService, RegisteredApp} from "projects/mastodon-api/src/public-api";
import {Account, MastodonApiAuthenticationService, RegisteredApp} from "projects/mastodon-api/src/public-api";
import {Store} from "@ngrx/store";
import {environment} from "../../../../../environments/environment";
import {NlNotificationService} from "../../../services/nl-notification.service";
@Injectable()
export class AuthorizationStateEffects {
@@ -17,7 +17,7 @@ export class AuthorizationStateEffects {
.createApp(action.instanceName, environment.appName, action.redirectUrl, environment.appWebsite)
.pipe(
map((registeredApp: RegisteredApp) => {
this.toastService.success('Successfully registered the application with the instance', 'Success');
this.notificationService.success('Successfully registered the application with the instance', 'success');
return fromAuthorize.registerApplicationSuccess({
id: registeredApp.id,
appName: registeredApp.name,
@@ -27,7 +27,7 @@ export class AuthorizationStateEffects {
})
}),
catchError((error) => {
this.toastService.danger('Could not register application. Please check the instance name.', 'Error');
this.notificationService.danger('Could not register application. Please check the instance name.', 'warning');
return of(fromAuthorize.registerApplicationError({error}));
}),
)
@@ -51,18 +51,18 @@ export class AuthorizationStateEffects {
this.mastodonApiAuthService
.verifyCredentials(result.action.instanceName, result.accessToken)
.pipe(
map((accountId) => {
return {accessToken: result.accessToken, accountId};
map((account: Account) => {
return {accessToken: result.accessToken, account};
})
)
),
map((result) => {
this.toastService.success('User successfully authenticated', 'Success');
return fromAuthorize.getAccessTokenSuccess({accessToken: result.accessToken, accountId: result.accountId});
this.notificationService.success('User successfully authenticated', 'success');
return fromAuthorize.getAccessTokenSuccess({accessToken: result.accessToken, account: result.account});
}),
catchError((error) => {
console.error(error);
this.toastService.danger('Could not get access token', 'Error');
this.notificationService.danger('Could not get access token', 'error');
return of(fromAuthorize.getAccessTokenError());
}),
)
@@ -80,7 +80,7 @@ export class AuthorizationStateEffects {
private store: Store,
private actions$: Actions,
private mastodonApiAuthService: MastodonApiAuthenticationService,
private toastService: NbToastrService,
private notificationService: NlNotificationService,
) {
}
}

View File

@@ -42,11 +42,11 @@ export const authenticationStateReducer = createReducer(
authorizingUser: true,
}
}),
on(fromAuthorize.getAccessTokenSuccess, (_state, {accessToken, accountId}) => {
on(fromAuthorize.getAccessTokenSuccess, (_state, {accessToken, account}) => {
return {
..._state,
accessToken,
accountId,
account,
authorizingUser: false,
};
}),

View File

@@ -5,7 +5,7 @@ export const authenticationStateFeature = createFeatureSelector<AuthenticationSt
export const selectIsLoggedIn = createSelector(
authenticationStateFeature,
(state: AuthenticationState) => state.instanceName !== undefined && state.accessToken !== undefined && state.accountId !== undefined
(state: AuthenticationState) => state.instanceName !== undefined && state.accessToken !== undefined && state.account !== undefined
)
export const selectDataForAuthorizedRequest = createSelector(
@@ -14,7 +14,7 @@ export const selectDataForAuthorizedRequest = createSelector(
return {
instanceName: state.instanceName,
accessToken: state.accessToken,
accountId: state.accountId,
accountId: state.account?.id,
}
}
);
@@ -34,6 +34,10 @@ export const selectCurrentInstance = createSelector(
authenticationStateFeature,
(state: AuthenticationState) => state.instanceName
)
export const selectCurrentAccount = createSelector(
authenticationStateFeature,
(state: AuthenticationState) => state.account
)
export const selectCurrentInstanceWithApplicationRegisteredState = createSelector(
authenticationStateFeature,
(state: AuthenticationState) => {

View File

@@ -3,7 +3,6 @@ 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({
@@ -14,9 +13,6 @@ import {NbCardModule, NbSpinnerModule} from "@nebular/theme";
CommonModule,
SharedModule,
SyncRoutingModule,
// Nebula
NbCardModule,
NbSpinnerModule,
]
})
export class SyncModule {

View File

@@ -1,45 +1,61 @@
<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%;">
<app-page>
<div title>Sync Remote Data with your Local Storage</div>
<div body>
<div class="mx-auto py-6 sm:px-6 lg:px-8">
<div class="overflow-hidden bg-white dark:bg-denim-darker shadow sm:rounded-lg">
<div class="px-4 sm:px-6">
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-white">Followings in Local Store</h3>
</div>
<div class="px-4 sm:px-6">
<p>Followings {{(followings$ | async)?.length}}</p>
</div>
</div>
</div>
<div class="mx-auto py-6 sm:px-6 lg:px-8">
<div class="overflow-hidden bg-white dark:bg-denim-darker shadow sm:rounded-lg">
<div class="px-4 sm:px-6">
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-white">Number of Accounts in Lists</h3>
</div>
<div class="m-10">
<p-table [value]="(lists$ | async)!" styleClass="p-datatable-striped">
<ng-template pTemplate="header">
<tr>
<th colspan="2">Followings</th>
<th pSortableColumn="title">
List
<p-sortIcon field="title"></p-sortIcon>
<p-columnFilter field="title" type="text" display="menu"></p-columnFilter>
</th>
<th pSortableColumn="accounts.length">
Accounts
<p-sortIcon field="accounts.length"></p-sortIcon>
</th>
</tr>
</ng-template>
<ng-template pTemplate="body" let-list>
<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>
</ng-template>
</p-table>
</div>
</div>
</div>
<div class="divider"></div>
<div class="mx-auto py-6 sm:px-6 lg:px-8">
<div class="flex flex-col">
<div>
<button pButton
type="button"
[loading]="(loading$ | async)!"
[disabled]="(loading$ | async)!"
(click)="loadListsAndAccounts()"
label="Get Lists and Accounts"
></button>
</div>
<div style="display:flex; flex-direction: column;" *ngIf="(instanceName$ | async) as instanceName">
<div class="flex flex-col pt-6" *ngIf="(instanceName$ | async) as instanceName">
<a [href]="'https://' + instanceName + '/settings/export'" rel="noreferrer" target="_blank">
Export lists using your account settings page
</a>
@@ -48,7 +64,7 @@
</a>
</div>
<div *ngIf="(lists$ | async) && (followings$ | async)">
<div class="pt-6" *ngIf="(lists$ | async) && (followings$ | async)">
<p>Now you can:</p>
<ul>
<li>Edit the lists <a [routerLink]="['/lists']">Lists</a></li>
@@ -56,7 +72,7 @@
<li>... or use the experimental <a [routerLink]="['/followings/matrix']">Matrix view</a></li>
</ul>
</div>
</nb-card-footer>
</nb-card>
</nb-card-body>
</nb-card>
</div>
</div>
</div>
</app-page>

View File

@@ -1,6 +1,6 @@
import {Component} from '@angular/core';
import {select, Store} from "@ngrx/store";
import {selectCurrentInstance, fromSyncPage, selectFollowings, selectListsWithAccounts, selectLoading} from "../../shared/state/store";
import {fromSyncPage, selectCurrentInstance, selectFollowings, selectListsWithAccounts, selectLoading} from "../../shared/state/store";
import {Observable} from "rxjs";
import {Account, List} from 'projects/mastodon-api/src/public-api';
@@ -12,7 +12,7 @@ import {Account, List} from 'projects/mastodon-api/src/public-api';
export class SyncComponent {
loading$: Observable<boolean>;
followings$: Observable<ReadonlyArray<Account>>;
lists$: Observable<ReadonlyArray<List>>;
lists$: Observable<List[]>;
instanceName$: Observable<string | undefined>;
constructor(private store: Store) {

View File

@@ -6,8 +6,9 @@
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<link id="app-theme" rel="stylesheet" type="text/css" href="light.css">
</head>
<body>
<app-root></app-root>
<app-root></app-root>
</body>
</html>

View File

@@ -1,3 +1,5 @@
/// <reference types="@angular/localize" />
import {platformBrowserDynamic} from '@angular/platform-browser-dynamic';
import {AppModule} from './app/app.module';

View File

@@ -1,23 +1,23 @@
// this is our just created themes.scss file, make sure the path to the file is correct
@use 'styles/themes' as *;
@tailwind base;
// framework component styles
@use '@nebular/theme/styles/globals' as *;
// install the framework styles
@include nb-install() {
@include nb-theme-global();
@layer base {
button {
border: 0;
}
body {
margin: 0px;
@apply dark:bg-denim-darker;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
}
a {
text-decoration: none;
@apply text-olympic-dark;
@apply dark:text-olympic-light;
}
a:hover {
@apply dark:text-olympic;
}
}
.divider {
height: 20px;
}
table, th, td {
border: 1px solid black;
border-collapse: collapse;
}
td, th {
padding: 10px;
}
@tailwind components;
@tailwind utilities;

Some files were not shown because too many files have changed in this diff Show More