initial commit
This commit is contained in:
104
.drone.yml
Normal file
104
.drone.yml
Normal file
@@ -0,0 +1,104 @@
|
||||
---
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: default
|
||||
|
||||
environment:
|
||||
K8S_REPO_URL: ssh://git@git.novaloop.ch:8022/novaloop-mastodon/mastodon-k8s.git
|
||||
|
||||
image_pull_secrets:
|
||||
- docker-auth-config
|
||||
|
||||
.image-build: &image-build
|
||||
- echo building docker image "git.novaloop.ch/${DRONE_REPO}:${DRONE_COMMIT_SHA}"
|
||||
- mkdir -p /kaniko/.docker
|
||||
- echo "$DOCKER_AUTH_CONFIG" > /kaniko/.docker/config.json
|
||||
- >-
|
||||
/kaniko/executor
|
||||
--context .
|
||||
--build-arg "ARG_APP_VERSION=${DRONE_TAG}"
|
||||
--cache=true
|
||||
--compressed-caching=false
|
||||
--snapshotMode=redo
|
||||
--use-new-run
|
||||
--dockerfile "deployment/$ENVIRONMENT/$ENVIRONMENT.dockerfile"
|
||||
--destination "git.novaloop.ch/${DRONE_REPO}:${DRONE_COMMIT_SHA}"
|
||||
--destination "git.novaloop.ch/${DRONE_REPO}:${DRONE_TAG}"
|
||||
--destination "git.novaloop.ch/${DRONE_REPO}:latest"
|
||||
|
||||
.image-deploy: &image-deploy
|
||||
- echo deploy ${DRONE_TAG} of ${DRONE_REPO} to $ENVIRONMENT
|
||||
# configure git
|
||||
- mkdir -p ~/.ssh
|
||||
- echo "$DRONE_CI_SSH_KEY" > ~/.ssh/id_rsa
|
||||
- chmod 600 ~/.ssh/id_rsa
|
||||
- ssh-keyscan -p 8022 -H git.novaloop.ch > ~/.ssh/known_hosts
|
||||
- git config --global user.name ${DRONE_REPO}
|
||||
- git config --global user.email ${DRONE_REPO}@git.novaloop.ch
|
||||
|
||||
# clone k8s repo
|
||||
- git clone -b "$ENVIRONMENT" "$K8S_REPO_URL" k8s
|
||||
|
||||
# update manifests
|
||||
- cd "k8s/mastolists/overlays/$ENVIRONMENT"
|
||||
- kustomize edit set image "git.novaloop.ch/${DRONE_REPO}=git.novaloop.ch/${DRONE_REPO}:${DRONE_TAG}"
|
||||
- git add .
|
||||
- git commit -m "deploy ${DRONE_REPO_NAME} updating $ENVIRONMENT to ${DRONE_TAG}"
|
||||
- git push
|
||||
|
||||
steps:
|
||||
- name: build staging image
|
||||
image: gcr.io/kaniko-project/executor:v1.9.1-debug # without DIND requirement
|
||||
commands:
|
||||
*image-build
|
||||
environment:
|
||||
ENVIRONMENT: staging
|
||||
DOCKER_AUTH_CONFIG:
|
||||
from_secret: docker-auth-config
|
||||
when:
|
||||
ref:
|
||||
include:
|
||||
- refs/tags/v[0-9]*.[0-9]*.[0-9]*-* # glob pattern matching (limited https://pkg.go.dev/github.com/bmatcuk/doublestar?utm_source=godoc)
|
||||
|
||||
- name: deploy staging image
|
||||
image: git.novaloop.ch/novaloop-hosting/gitlab-build-docker-image:latest
|
||||
commands:
|
||||
*image-deploy
|
||||
environment:
|
||||
ENVIRONMENT: staging
|
||||
DRONE_CI_SSH_KEY:
|
||||
from_secret: DRONE_CI_SSH_KEY
|
||||
when:
|
||||
ref:
|
||||
include:
|
||||
- refs/tags/v[0-9]*.[0-9]*.[0-9]*-* # glob pattern matching (limited https://pkg.go.dev/github.com/bmatcuk/doublestar?utm_source=godoc)
|
||||
|
||||
- name: build production image
|
||||
image: gcr.io/kaniko-project/executor:v1.9.1-debug # without DIND requirement
|
||||
commands:
|
||||
*image-build
|
||||
environment:
|
||||
ENVIRONMENT: production
|
||||
DOCKER_AUTH_CONFIG:
|
||||
from_secret: docker-auth-config
|
||||
when:
|
||||
ref:
|
||||
include:
|
||||
- refs/tags/v[0-9]*.[0-9]*.[0-9]* # glob pattern matching (limited https://pkg.go.dev/github.com/bmatcuk/doublestar?utm_source=godoc)
|
||||
exclude:
|
||||
- refs/tags/v[0-9]*.[0-9]*.[0-9]*-*
|
||||
|
||||
- name: deploy production image
|
||||
image: git.novaloop.ch/novaloop-hosting/gitlab-build-docker-image:latest
|
||||
commands:
|
||||
*image-deploy
|
||||
environment:
|
||||
ENVIRONMENT: production
|
||||
DRONE_CI_SSH_KEY:
|
||||
from_secret: DRONE_CI_SSH_KEY
|
||||
when:
|
||||
ref:
|
||||
include:
|
||||
- refs/tags/v[0-9]*.[0-9]*.[0-9]* # glob pattern matching (limited https://pkg.go.dev/github.com/bmatcuk/doublestar?utm_source=godoc)
|
||||
exclude:
|
||||
- refs/tags/v[0-9]*.[0-9]*.[0-9]*-*
|
||||
16
.editorconfig
Normal file
16
.editorconfig
Normal file
@@ -0,0 +1,16 @@
|
||||
# Editor configuration, see https://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.ts]
|
||||
quote_type = single
|
||||
|
||||
[*.md]
|
||||
max_line_length = off
|
||||
trim_trailing_whitespace = false
|
||||
44
.gitignore
vendored
Normal file
44
.gitignore
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
# See http://help.github.com/ignore-files/ for more about ignoring files.
|
||||
|
||||
.vscode
|
||||
|
||||
# Compiled output
|
||||
/dist
|
||||
/tmp
|
||||
/out-tsc
|
||||
/bazel-out
|
||||
|
||||
# Node
|
||||
/node_modules
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# IDEs and editors
|
||||
.idea/
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# Visual Studio Code
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
.history/*
|
||||
|
||||
# Miscellaneous
|
||||
/.angular/cache
|
||||
.sass-cache/
|
||||
/connect.lock
|
||||
/coverage
|
||||
/libpeerconnection.log
|
||||
testem.log
|
||||
/typings
|
||||
|
||||
# System files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
27
README.md
Normal file
27
README.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Mastodon
|
||||
|
||||
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 15.0.2.
|
||||
|
||||
## Development server
|
||||
|
||||
Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of the source files.
|
||||
|
||||
## Code scaffolding
|
||||
|
||||
Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`.
|
||||
|
||||
## Build
|
||||
|
||||
Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory.
|
||||
|
||||
## Running unit tests
|
||||
|
||||
Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io).
|
||||
|
||||
## Running end-to-end tests
|
||||
|
||||
Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities.
|
||||
|
||||
## Further help
|
||||
|
||||
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.io/cli) page.
|
||||
139
angular.json
Normal file
139
angular.json
Normal file
@@ -0,0 +1,139 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"mastolists": {
|
||||
"projectType": "application",
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"style": "scss"
|
||||
}
|
||||
},
|
||||
"root": "projects/mastolists",
|
||||
"sourceRoot": "projects/mastolists/src",
|
||||
"prefix": "app",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:browser",
|
||||
"options": {
|
||||
"outputPath": "dist/mastolists",
|
||||
"index": "projects/mastolists/src/index.html",
|
||||
"main": "projects/mastolists/src/main.ts",
|
||||
"polyfills": [
|
||||
"zone.js"
|
||||
],
|
||||
"tsConfig": "projects/mastolists/tsconfig.app.json",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": [
|
||||
"projects/mastolists/src/favicon.ico",
|
||||
"projects/mastolists/src/assets"
|
||||
],
|
||||
"styles": [
|
||||
"projects/mastolists/src/styles.scss"
|
||||
],
|
||||
"scripts": []
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"budgets": [{
|
||||
"type": "initial",
|
||||
"maximumWarning": "2mb",
|
||||
"maximumError": "3mb"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "2kb",
|
||||
"maximumError": "4kb"
|
||||
}
|
||||
],
|
||||
"outputHashing": "all"
|
||||
},
|
||||
"development": {
|
||||
"buildOptimizer": false,
|
||||
"optimization": false,
|
||||
"vendorChunk": true,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true,
|
||||
"namedChunks": true
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"configurations": {
|
||||
"production": {
|
||||
"browserTarget": "mastolists:build:production"
|
||||
},
|
||||
"development": {
|
||||
"browserTarget": "mastolists:build:development"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
},
|
||||
"extract-i18n": {
|
||||
"builder": "@angular-devkit/build-angular:extract-i18n",
|
||||
"options": {
|
||||
"browserTarget": "mastolists:build"
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular-devkit/build-angular:karma",
|
||||
"options": {
|
||||
"polyfills": [
|
||||
"zone.js",
|
||||
"zone.js/testing"
|
||||
],
|
||||
"tsConfig": "projects/mastolists/tsconfig.spec.json",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": [
|
||||
"projects/mastolists/src/favicon.ico",
|
||||
"projects/mastolists/src/assets"
|
||||
],
|
||||
"styles": [
|
||||
"projects/mastolists/src/styles.scss"
|
||||
],
|
||||
"scripts": []
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"mastodon-api": {
|
||||
"projectType": "library",
|
||||
"root": "projects/mastodon-api",
|
||||
"sourceRoot": "projects/mastodon-api/src",
|
||||
"prefix": "lib",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:ng-packagr",
|
||||
"options": {
|
||||
"project": "projects/mastodon-api/ng-package.json"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"tsConfig": "projects/mastodon-api/tsconfig.lib.prod.json"
|
||||
},
|
||||
"development": {
|
||||
"tsConfig": "projects/mastodon-api/tsconfig.lib.json"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular-devkit/build-angular:karma",
|
||||
"options": {
|
||||
"tsConfig": "projects/mastodon-api/tsconfig.spec.json",
|
||||
"polyfills": [
|
||||
"zone.js",
|
||||
"zone.js/testing"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cli": {
|
||||
"analytics": "48f19321-a0cb-4b6d-8ea6-27d1fd0792f5"
|
||||
}
|
||||
}
|
||||
20
deployment/nginx.conf
Normal file
20
deployment/nginx.conf
Normal file
@@ -0,0 +1,20 @@
|
||||
server {
|
||||
listen 0.0.0.0:8080;
|
||||
listen [::]:8080;
|
||||
default_type application/octet-stream;
|
||||
|
||||
gzip on;
|
||||
gzip_comp_level 6;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1000;
|
||||
gzip_proxied any;
|
||||
gzip_types text/plain text/css application/json application/x-javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
gzip_buffers 16 8k;
|
||||
client_max_body_size 256M;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html =404;
|
||||
}
|
||||
}
|
||||
17
deployment/production/production.dockerfile
Normal file
17
deployment/production/production.dockerfile
Normal file
@@ -0,0 +1,17 @@
|
||||
FROM node:lts-alpine AS build-env
|
||||
|
||||
WORKDIR /app
|
||||
COPY ./package.json ./
|
||||
COPY ./package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY ./ ./
|
||||
ARG ARG_APP_VERSION
|
||||
ENV APP_VERSION=$ARG_APP_VERSION
|
||||
RUN npm version -no-git-tag-version $APP_VERSION
|
||||
RUN node ./node_modules/@angular/cli/bin/ng build mastolists --configuration=production
|
||||
|
||||
|
||||
FROM nginx:alpine
|
||||
LABEL author="Novaloop AG"
|
||||
COPY --from=build-env /app/dist/mastolists /usr/share/nginx/html
|
||||
COPY ./deployment/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
17
deployment/staging/staging.dockerfile
Normal file
17
deployment/staging/staging.dockerfile
Normal file
@@ -0,0 +1,17 @@
|
||||
FROM node:lts-alpine AS build-env
|
||||
|
||||
WORKDIR /app
|
||||
COPY ./package.json ./
|
||||
COPY ./package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY ./ ./
|
||||
ARG ARG_APP_VERSION
|
||||
ENV APP_VERSION=$ARG_APP_VERSION
|
||||
RUN npm version -no-git-tag-version $APP_VERSION
|
||||
RUN node ./node_modules/@angular/cli/bin/ng build mastolists --configuration=staging
|
||||
|
||||
|
||||
FROM nginx:alpine
|
||||
LABEL author="Novaloop AG"
|
||||
COPY --from=build-env /app/dist/mastolists /usr/share/nginx/html
|
||||
COPY ./deployment/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
21194
package-lock.json
generated
Normal file
21194
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
79
package.json
Normal file
79
package.json
Normal file
@@ -0,0 +1,79 @@
|
||||
{
|
||||
"name": "mastodon",
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"build": "ng build",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "ng test"
|
||||
},
|
||||
"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",
|
||||
"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",
|
||||
"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/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",
|
||||
"jasmine-core": "~4.5.0",
|
||||
"karma": "~6.4.0",
|
||||
"karma-chrome-launcher": "~3.1.0",
|
||||
"karma-coverage": "~2.2.0",
|
||||
"karma-jasmine": "~5.1.0",
|
||||
"karma-jasmine-html-reporter": "~2.0.0",
|
||||
"ng-packagr": "^15.0.0",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
24
projects/mastodon-api/README.md
Normal file
24
projects/mastodon-api/README.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# MastodonApi
|
||||
|
||||
This library was generated with [Angular CLI](https://github.com/angular/angular-cli) version 15.0.0.
|
||||
|
||||
## Code scaffolding
|
||||
|
||||
Run `ng generate component component-name --project mastodon-api` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module --project mastodon-api`.
|
||||
> Note: Don't forget to add `--project mastodon-api` or else it will be added to the default project in your `angular.json` file.
|
||||
|
||||
## Build
|
||||
|
||||
Run `ng build mastodon-api` to build the project. The build artifacts will be stored in the `dist/` directory.
|
||||
|
||||
## Publishing
|
||||
|
||||
After building your library with `ng build mastodon-api`, go to the dist folder `cd dist/mastodon-api` and run `npm publish`.
|
||||
|
||||
## Running unit tests
|
||||
|
||||
Run `ng test mastodon-api` to execute the unit tests via [Karma](https://karma-runner.github.io).
|
||||
|
||||
## Further help
|
||||
|
||||
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.io/cli) page.
|
||||
7
projects/mastodon-api/ng-package.json
Normal file
7
projects/mastodon-api/ng-package.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
|
||||
"dest": "../../dist/mastodon-api",
|
||||
"lib": {
|
||||
"entryFile": "src/public-api.ts"
|
||||
}
|
||||
}
|
||||
11
projects/mastodon-api/package.json
Normal file
11
projects/mastodon-api/package.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "mastodon-api",
|
||||
"version": "0.0.1",
|
||||
"peerDependencies": {
|
||||
"@angular/common": "^15.0.0",
|
||||
"@angular/core": "^15.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
}
|
||||
}
|
||||
1
projects/mastodon-api/src/lib/interfaces/interfaces.ts
Normal file
1
projects/mastodon-api/src/lib/interfaces/interfaces.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './public/public';
|
||||
20
projects/mastodon-api/src/lib/interfaces/public/account.ts
Normal file
20
projects/mastodon-api/src/lib/interfaces/public/account.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import {List} from "./list";
|
||||
|
||||
export interface Field {
|
||||
name: string;
|
||||
value: string;
|
||||
verified_at: Date;
|
||||
}
|
||||
|
||||
export interface Account {
|
||||
id: string;
|
||||
username: string;
|
||||
acct: string;
|
||||
url: string;
|
||||
displayName: string;
|
||||
note: string;
|
||||
avatar: string;
|
||||
avatarStatic: string;
|
||||
fields: Field[];
|
||||
lists: List[];
|
||||
}
|
||||
8
projects/mastodon-api/src/lib/interfaces/public/list.ts
Normal file
8
projects/mastodon-api/src/lib/interfaces/public/list.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import {Account} from "./account";
|
||||
|
||||
export interface List {
|
||||
id: string;
|
||||
title: string;
|
||||
repliesPolicy: string;
|
||||
accounts: Account[]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './account';
|
||||
export * from './list';
|
||||
export * from './registered_app';
|
||||
export * from './user_account';
|
||||
@@ -0,0 +1,9 @@
|
||||
export interface RegisteredApp{
|
||||
id: string;
|
||||
name: string;
|
||||
website: string;
|
||||
redirectUri: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
vapidKey: string;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface UserAccount {
|
||||
id: string;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface GetAccessTokenResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
scope: string;
|
||||
created_at: Date;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export interface Field {
|
||||
name: string;
|
||||
value: string;
|
||||
verified_at: Date;
|
||||
}
|
||||
export interface GetAccountResponse {
|
||||
id: string;
|
||||
username: string;
|
||||
acct: string;
|
||||
url: string;
|
||||
display_name: string;
|
||||
note: string;
|
||||
avatar: string;
|
||||
avatar_static: string;
|
||||
fields: Field[];
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import {GetAccountResponse} from "./get_account_response";
|
||||
|
||||
export interface GetListsResponse {
|
||||
id: string;
|
||||
title: string;
|
||||
repliesPolicy: string;
|
||||
accounts: GetAccountResponse[]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export interface RegisterAppResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
website: string;
|
||||
redirect_uri: string;
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
vapid_key: string;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface VerifyCredentialsResponse {
|
||||
id: string;
|
||||
}
|
||||
21
projects/mastodon-api/src/lib/mastodon-api.module.ts
Normal file
21
projects/mastodon-api/src/lib/mastodon-api.module.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import {ModuleWithProviders, NgModule} from '@angular/core';
|
||||
import {MastodonApiListsService} from "./services/mastodon-api-lists.service";
|
||||
import {MastodonApiAuthenticationService} from "./services/mastodon-api-authentication.service";
|
||||
import {HttpClientModule} from "@angular/common/http";
|
||||
|
||||
|
||||
@NgModule({
|
||||
declarations: [],
|
||||
imports: [
|
||||
HttpClientModule
|
||||
],
|
||||
exports: []
|
||||
})
|
||||
export class MastodonApiModule {
|
||||
static forRoot(): ModuleWithProviders<MastodonApiModule> {
|
||||
return {
|
||||
ngModule: MastodonApiModule,
|
||||
providers: [MastodonApiListsService, MastodonApiAuthenticationService]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import {Injectable} from "@angular/core";
|
||||
import {MastodonApiService} from "./mastodon-api.service";
|
||||
import {map, Observable} from "rxjs";
|
||||
import {GetAccountResponse} from "../interfaces/responses/get_account_response";
|
||||
import {Account} from "../interfaces/public/account";
|
||||
import {HttpResponse} from "@angular/common/http";
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class MastodonApiAccountsService {
|
||||
|
||||
constructor(private mastodonApiService: MastodonApiService) {
|
||||
}
|
||||
|
||||
getFollowingsForAccount(instanceName: string, accessToken: string, accountId: string, url: string = ''): Observable<[nextLink: string, accounts: Account[]]> {
|
||||
if (url === '') {
|
||||
url = `https://${instanceName}/api/v1/accounts/${accountId}/following?limit=80`;
|
||||
}
|
||||
return this.mastodonApiService
|
||||
.getAuthenticatedWithResponseHeaders<GetAccountResponse[]>(url, accessToken)
|
||||
.pipe(
|
||||
map((response) => {
|
||||
const links = response.headers.get('link');
|
||||
let rel = '';
|
||||
let nextUrl = '';
|
||||
if (links) {
|
||||
const nextLink = links!.split(',')[0];
|
||||
rel = nextLink.split(';')[1].replace(' rel="', '').replace('"', '');
|
||||
nextUrl = nextLink.split(';')[0].replace('<', '').replace('>', '');
|
||||
}
|
||||
|
||||
const accounts = response.body!.map((response) => {
|
||||
return <Account>{
|
||||
id: response.id,
|
||||
username: response.username,
|
||||
acct: response.acct,
|
||||
displayName: response.display_name,
|
||||
note: response.note,
|
||||
url: response.url,
|
||||
avatar: response.avatar,
|
||||
avatarStatic: response.avatar_static,
|
||||
fields: response.fields,
|
||||
};
|
||||
});
|
||||
return [rel === 'next' ? nextUrl : '', accounts];
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import {Injectable} from '@angular/core';
|
||||
import {map, Observable} 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";
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class MastodonApiAuthenticationService {
|
||||
|
||||
constructor(private mastodonApiService: MastodonApiService) {
|
||||
}
|
||||
|
||||
createApp(instance: string, clientName: string, redirectUrl: string, website: string): Observable<RegisteredApp> {
|
||||
const scopes = ['read', 'write']; // no 'follow'
|
||||
|
||||
const parameters: {
|
||||
client_name: string,
|
||||
redirect_uris: string,
|
||||
scopes: string,
|
||||
website: string
|
||||
} = {
|
||||
client_name: clientName,
|
||||
redirect_uris: redirectUrl,
|
||||
scopes: scopes.join(' '),
|
||||
website
|
||||
}
|
||||
const url = `https://${instance}/api/v1/apps`;
|
||||
return this.mastodonApiService
|
||||
.post<RegisterAppResponse>(url, parameters)
|
||||
.pipe(map((response) => {
|
||||
return <RegisteredApp>{
|
||||
id: response.id,
|
||||
name: response.name,
|
||||
website: response.website,
|
||||
redirectUri: response.redirect_uri,
|
||||
clientId: response.client_id,
|
||||
clientSecret: response.client_secret,
|
||||
vapidKey: response.vapid_key,
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
authorizeUser(instance: string, clientId: string, redirectUrl: string) {
|
||||
const parameters = [
|
||||
['response_type', 'code'].join("="),
|
||||
['scope', 'read write'].join("="),
|
||||
['client_id', clientId].join("="),
|
||||
['redirect_uri', redirectUrl].join("="),
|
||||
].join("&");
|
||||
window.location.href = `https://${instance}/oauth/authorize?${parameters}`;
|
||||
}
|
||||
|
||||
verifyCredentials(instance: string, accessToken: string): Observable<string> {
|
||||
const url = `https://${instance}/api/v1/accounts/verify_credentials`;
|
||||
return this.mastodonApiService
|
||||
.getAuthenticated<VerifyCredentialsResponse>(url, accessToken)
|
||||
.pipe(map((response) => response.id));
|
||||
|
||||
}
|
||||
|
||||
getAccessToken(instanceName: string, clientId: string, clientSecret: string, redirectUrl: string, code: string): Observable<string> {
|
||||
const parameters: {
|
||||
client_id: string,
|
||||
client_secret: string,
|
||||
redirect_uri: string,
|
||||
grant_type: string,
|
||||
code: string,
|
||||
scope: string
|
||||
} = {
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
redirect_uri: redirectUrl,
|
||||
grant_type: 'authorization_code',
|
||||
code: code,
|
||||
scope: 'read write'
|
||||
}
|
||||
const url = `https://${instanceName}/oauth/token`;
|
||||
return this.mastodonApiService
|
||||
.post<GetAccessTokenResponse>(url, parameters)
|
||||
.pipe(map((response) => response.access_token));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import {Injectable} from '@angular/core';
|
||||
import {concatMap, from, map, mergeAll, mergeMap, Observable, reduce, switchMap, tap, toArray} from "rxjs";
|
||||
import {VerifyCredentialsResponse} from "../interfaces/responses/verify_credentials_response";
|
||||
import {MastodonApiService} from "./mastodon-api.service";
|
||||
import {GetListsResponse} from "../interfaces/responses/get_lists_response";
|
||||
import {GetAccountResponse} from "../interfaces/responses/get_account_response";
|
||||
import {List} from "../interfaces/public/list";
|
||||
import {Account} from "../interfaces/public/account";
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class MastodonApiListsService {
|
||||
|
||||
constructor(private mastodonApiService: MastodonApiService) {
|
||||
}
|
||||
|
||||
getLists(instanceName: string, accessToken: string): Observable<List[]> {
|
||||
const url = `https://${instanceName}/api/v1/lists`;
|
||||
return this.mastodonApiService
|
||||
.getAuthenticated<GetListsResponse[]>(url, accessToken)
|
||||
.pipe(map((responses) => {
|
||||
return responses.map((response) => {
|
||||
return <List>{
|
||||
id: response.id,
|
||||
title: response.title,
|
||||
repliesPolicy: response.repliesPolicy,
|
||||
}
|
||||
})
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
getAccountsForList(instanceName: string, accessToken: string, listId: string, url: string = ''): Observable<[nextLink: string, accounts: Account[]]> {
|
||||
if (url === '') {
|
||||
url = `https://${instanceName}/api/v1/lists/${listId}/accounts`;
|
||||
}
|
||||
return this.mastodonApiService
|
||||
.getAuthenticatedWithResponseHeaders<GetAccountResponse[]>(url, accessToken)
|
||||
.pipe(map(response => {
|
||||
const links = response.headers.get('link');
|
||||
let rel = '';
|
||||
let nextUrl = '';
|
||||
if (links) {
|
||||
const nextLink = links!.split(',')[0];
|
||||
rel = nextLink.split(';')[1].replace(' rel="', '').replace('"', '');
|
||||
nextUrl = nextLink.split(';')[0].replace('<', '').replace('>', '');
|
||||
}
|
||||
const accounts = response!.body!.map((response) => {
|
||||
return <Account>{
|
||||
id: response.id,
|
||||
username: response.username,
|
||||
acct: response.acct,
|
||||
displayName: response.display_name,
|
||||
note: response.note,
|
||||
url: response.url,
|
||||
avatar: response.avatar,
|
||||
avatarStatic: response.avatar_static,
|
||||
fields: response.fields,
|
||||
};
|
||||
});
|
||||
return [rel === 'next' ? nextUrl : '', accounts];
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
addAccountToList(instanceName: string, accessToken: string, listId: string, accountId: string) {
|
||||
const url = `https://${instanceName}/api/v1/lists/${listId}/accounts`;
|
||||
return this.mastodonApiService
|
||||
.postAuthenticated(url, {account_ids: [accountId]}, accessToken);
|
||||
}
|
||||
|
||||
removeAccountFromList(instanceName: string, accessToken: string, listId: string, accountId: string) {
|
||||
const url = `https://${instanceName}/api/v1/lists/${listId}/accounts`;
|
||||
return this.mastodonApiService
|
||||
.deleteAuthenticated(url, {account_ids: [accountId]}, accessToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import {Injectable} from "@angular/core";
|
||||
import {Observable} from "rxjs";
|
||||
import {HttpClient, HttpHeaders, HttpResponse} from "@angular/common/http";
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class MastodonApiService {
|
||||
constructor(private httpClient: HttpClient) {
|
||||
}
|
||||
|
||||
get<T>(url: string) {
|
||||
return this.httpClient.get<T>(url);
|
||||
}
|
||||
|
||||
getAuthenticated<T>(url: string, accessToken: string): Observable<T> {
|
||||
const reqHeader = new HttpHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ' + accessToken
|
||||
});
|
||||
return this.httpClient.get<T>(url, {headers: reqHeader});
|
||||
}
|
||||
|
||||
getAuthenticatedWithResponseHeaders<T>(url: string, accessToken: string): Observable<HttpResponse<T>> {
|
||||
const reqHeader = new HttpHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ' + accessToken
|
||||
});
|
||||
return this.httpClient.get<T>(url, {headers: reqHeader, observe: 'response'});
|
||||
}
|
||||
|
||||
post<T>(url: string, parameters: object) {
|
||||
return this.httpClient.post<T>(url, parameters);
|
||||
}
|
||||
|
||||
postAuthenticated(url: string, body: { account_ids: string[] }, accessToken: string) {
|
||||
const reqHeader = new HttpHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ' + accessToken
|
||||
});
|
||||
return this.httpClient.post(url, body, {headers: reqHeader});
|
||||
}
|
||||
|
||||
deleteAuthenticated(url: string, body: { account_ids: string[] }, accessToken: string) {
|
||||
const reqHeader = new HttpHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ' + accessToken
|
||||
});
|
||||
return this.httpClient.delete(url, {headers: reqHeader, body});
|
||||
}
|
||||
}
|
||||
4
projects/mastodon-api/src/lib/services/services.ts
Normal file
4
projects/mastodon-api/src/lib/services/services.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './mastodon-api.service';
|
||||
export * from './mastodon-api-accounts.service';
|
||||
export * from './mastodon-api-authentication.service';
|
||||
export * from './mastodon-api-lists.service';
|
||||
6
projects/mastodon-api/src/public-api.ts
Normal file
6
projects/mastodon-api/src/public-api.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/*
|
||||
* Public API Surface of mastodon-api
|
||||
*/
|
||||
|
||||
export * from './lib/services/services';
|
||||
export * from './lib/interfaces/interfaces';
|
||||
14
projects/mastodon-api/tsconfig.lib.json
Normal file
14
projects/mastodon-api/tsconfig.lib.json
Normal file
@@ -0,0 +1,14 @@
|
||||
/* To learn more about this file see: https://angular.io/config/tsconfig. */
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../out-tsc/lib",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"inlineSources": true,
|
||||
"types": []
|
||||
},
|
||||
"exclude": [
|
||||
"**/*.spec.ts"
|
||||
]
|
||||
}
|
||||
10
projects/mastodon-api/tsconfig.lib.prod.json
Normal file
10
projects/mastodon-api/tsconfig.lib.prod.json
Normal file
@@ -0,0 +1,10 @@
|
||||
/* To learn more about this file see: https://angular.io/config/tsconfig. */
|
||||
{
|
||||
"extends": "./tsconfig.lib.json",
|
||||
"compilerOptions": {
|
||||
"declarationMap": false
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"compilationMode": "partial"
|
||||
}
|
||||
}
|
||||
14
projects/mastodon-api/tsconfig.spec.json
Normal file
14
projects/mastodon-api/tsconfig.spec.json
Normal file
@@ -0,0 +1,14 @@
|
||||
/* To learn more about this file see: https://angular.io/config/tsconfig. */
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../out-tsc/spec",
|
||||
"types": [
|
||||
"jasmine"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"**/*.spec.ts",
|
||||
"**/*.d.ts"
|
||||
]
|
||||
}
|
||||
41
projects/mastolists/src/app/app-routing.module.ts
Normal file
41
projects/mastolists/src/app/app-routing.module.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import {NgModule} from '@angular/core';
|
||||
import {RouterModule, Routes} from '@angular/router';
|
||||
import {AuthGuard} from './shared/guards/auth.guard';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
redirectTo: 'home',
|
||||
pathMatch: 'full'
|
||||
},
|
||||
{
|
||||
path: 'home',
|
||||
loadChildren: () => import('./home/home.module').then(m => m.HomeModule),
|
||||
},
|
||||
{
|
||||
path: 'auth',
|
||||
loadChildren: () => import('./authorization/authorization.module').then(m => m.AuthorizationModule),
|
||||
},
|
||||
{
|
||||
path: 'sync',
|
||||
loadChildren: () => import('./sync/sync.module').then(m => m.SyncModule),
|
||||
canActivate: [AuthGuard],
|
||||
},
|
||||
{
|
||||
path: 'followings',
|
||||
loadChildren: () => import('./followings/followings.module').then(m => m.FollowingsModule),
|
||||
canActivate: [AuthGuard],
|
||||
},
|
||||
{
|
||||
path: 'matrix',
|
||||
loadChildren: () => import('./followings-matrix/followings-matrix.module').then(m => m.FollowingsMatrixModule),
|
||||
canActivate: [AuthGuard],
|
||||
},
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forRoot(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class AppRoutingModule {
|
||||
}
|
||||
42
projects/mastolists/src/app/app.component.html
Normal file
42
projects/mastolists/src/app/app.component.html
Normal file
@@ -0,0 +1,42 @@
|
||||
<ng-progress [spinner]="false" [debounceTime]="300" [min]="20" [color]="'#83ebd6'" [thick]="true"></ng-progress>
|
||||
<nb-layout center>
|
||||
<nb-layout-header subheader>
|
||||
<div class="navbar-container">
|
||||
<div id="logo" style="padding-right: 20px;">
|
||||
<a [routerLink]="['/']">
|
||||
<img height="40px" src="assets/images/novaloop.png" alt="Novaloop favicon">
|
||||
</a>
|
||||
</div>
|
||||
<div><h2>Mastolists</h2></div>
|
||||
<!--add class button-responsive to the button-->
|
||||
<button nbButton ghost class="button-responsive" [nbContextMenu]="navigationItems">
|
||||
<nb-icon icon="menu-outline"></nb-icon>
|
||||
</button>
|
||||
<!--add class menu-responsive to nb-actions-->
|
||||
<nb-actions class="left menu-responsive">
|
||||
<nb-action *ngFor="let item of navigationItems"
|
||||
[routerLink]="item.link"
|
||||
[title]="item.title">
|
||||
{{ item.title }}
|
||||
</nb-action>
|
||||
</nb-actions>
|
||||
</div>
|
||||
</nb-layout-header>
|
||||
|
||||
<nb-layout-column>
|
||||
<router-outlet></router-outlet>
|
||||
</nb-layout-column>
|
||||
|
||||
<nb-layout-footer>
|
||||
<div style="width: 50%">
|
||||
Novaloop AG<br/>
|
||||
Niederdorfstrasse 88<br/>
|
||||
8001 Zürich<br/>
|
||||
</div>
|
||||
<div style="width: 50%; text-align: right;">
|
||||
<a href="tel:+41 44 500 54 60">+41 44 500 54 60</a><br/>
|
||||
<a href="https://www.novaloop.ch">www.novaloop.ch</a><br/>
|
||||
<a href="mailto:mail@novaloop.ch">mail@novaloop.ch</a><br/>
|
||||
</div>
|
||||
</nb-layout-footer>
|
||||
</nb-layout>
|
||||
24
projects/mastolists/src/app/app.component.scss
Normal file
24
projects/mastolists/src/app/app.component.scss
Normal file
@@ -0,0 +1,24 @@
|
||||
.button-responsive {
|
||||
display: none
|
||||
}
|
||||
|
||||
@media (max-width: 573px) {
|
||||
.button-responsive {
|
||||
display: inline-block
|
||||
}
|
||||
.menu-responsive {
|
||||
display: none
|
||||
}
|
||||
}
|
||||
|
||||
nb-action {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.navbar-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
}
|
||||
35
projects/mastolists/src/app/app.component.spec.ts
Normal file
35
projects/mastolists/src/app/app.component.spec.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { AppComponent } from './app.component';
|
||||
|
||||
describe('AppComponent', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
RouterTestingModule
|
||||
],
|
||||
declarations: [
|
||||
AppComponent
|
||||
],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
it('should create the app', () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
const app = fixture.componentInstance;
|
||||
expect(app).toBeTruthy();
|
||||
});
|
||||
|
||||
it(`should have as title 'mastolists'`, () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
const app = fixture.componentInstance;
|
||||
expect(app.title).toEqual('mastolists');
|
||||
});
|
||||
|
||||
it('should render title', () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.querySelector('.content span')?.textContent).toContain('mastolists app is running!');
|
||||
});
|
||||
});
|
||||
29
projects/mastolists/src/app/app.component.ts
Normal file
29
projects/mastolists/src/app/app.component.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import {Component, isDevMode} from '@angular/core';
|
||||
import {select, Store} from "@ngrx/store";
|
||||
import {MastodonApiActions} from "./shared/state/store/actions";
|
||||
import {Observable, tap} from "rxjs";
|
||||
import {selectLoadingPercentage} from "./shared/state/store/selectors";
|
||||
import {PersistentStore} from "./shared/state/persistent/persistent-store.service";
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
templateUrl: './app.component.html',
|
||||
styleUrls: ['./app.component.scss']
|
||||
})
|
||||
export class AppComponent {
|
||||
title = 'mastolists';
|
||||
|
||||
navigationItems = [
|
||||
{title: 'Authorize', link: '/auth'},
|
||||
{title: 'Stats', link: '/sync'},
|
||||
{title: 'List view', link: '/followings'},
|
||||
{title: 'Matrix View', link: '/matrix'},
|
||||
];
|
||||
|
||||
constructor(private store: Store, private persistentStore: PersistentStore) {
|
||||
if (this.persistentStore.isAuthorized() && !isDevMode()) {
|
||||
this.store.dispatch(MastodonApiActions.loadLists());
|
||||
this.store.dispatch(MastodonApiActions.loadFollowings());
|
||||
}
|
||||
}
|
||||
}
|
||||
73
projects/mastolists/src/app/app.module.ts
Normal file
73
projects/mastolists/src/app/app.module.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import {NgModule} from '@angular/core';
|
||||
import {BrowserModule} from '@angular/platform-browser';
|
||||
|
||||
import {AppRoutingModule} from './app-routing.module';
|
||||
import {AppComponent} from './app.component';
|
||||
import {MastodonApiModule} from "../../../mastodon-api/src/lib/mastodon-api.module";
|
||||
import {
|
||||
NbActionsModule,
|
||||
NbContextMenuModule, NbDialogModule,
|
||||
NbGlobalPhysicalPosition,
|
||||
NbLayoutModule,
|
||||
NbMenuModule, NbProgressBarModule,
|
||||
NbThemeModule,
|
||||
NbToastrModule
|
||||
} from "@nebular/theme";
|
||||
import {BrowserAnimationsModule} from "@angular/platform-browser/animations";
|
||||
import {SharedModule} from './shared/shared.module';
|
||||
import {NbEvaIconsModule} from "@nebular/eva-icons";
|
||||
import {StoreModule} from '@ngrx/store';
|
||||
import {StoreDevtoolsModule} from "@ngrx/store-devtools";
|
||||
import {applicationStateReducers} from "./shared/state/store/reducers";
|
||||
import {EffectsModule} from "@ngrx/effects";
|
||||
import {ApplicationEffects} from "./shared/state/store/effects";
|
||||
import {NgProgressModule} from "ngx-progressbar";
|
||||
import {NgProgressHttpModule} from "ngx-progressbar/http";
|
||||
|
||||
const toastrConfig = {
|
||||
duration: 3000,
|
||||
position: NbGlobalPhysicalPosition.BOTTOM_RIGHT,
|
||||
preventDuplicates: true,
|
||||
destroyByClick: true,
|
||||
};
|
||||
|
||||
@NgModule({
|
||||
bootstrap: [AppComponent],
|
||||
declarations: [
|
||||
AppComponent,
|
||||
],
|
||||
imports: [
|
||||
BrowserModule,
|
||||
BrowserAnimationsModule,
|
||||
AppRoutingModule,
|
||||
MastodonApiModule.forRoot(),
|
||||
StoreModule.forRoot({}),
|
||||
StoreModule.forFeature('applicationState', applicationStateReducers),
|
||||
EffectsModule.forRoot(ApplicationEffects),
|
||||
StoreDevtoolsModule.instrument({
|
||||
maxAge: 25,
|
||||
logOnly: false,
|
||||
autoPause: true,
|
||||
features: {
|
||||
pause: false,
|
||||
lock: true,
|
||||
persist: true
|
||||
}
|
||||
}),
|
||||
SharedModule,
|
||||
NgProgressModule,
|
||||
NgProgressHttpModule,
|
||||
// Nebula
|
||||
NbContextMenuModule,
|
||||
NbMenuModule.forRoot(),
|
||||
NbDialogModule.forRoot(),
|
||||
NbEvaIconsModule,
|
||||
NbActionsModule,
|
||||
NbThemeModule.forRoot({name: 'dark'}),
|
||||
NbToastrModule.forRoot(toastrConfig),
|
||||
NbLayoutModule,
|
||||
],
|
||||
providers: []
|
||||
})
|
||||
export class AppModule {
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import {RouterModule, Routes} from "@angular/router";
|
||||
import {NgModule} from "@angular/core";
|
||||
import {AuthorizeComponent} from "./authorize/authorize.component";
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: AuthorizeComponent
|
||||
}
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class AuthorizationRoutingModule {
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import {NgModule} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {AuthorizationRoutingModule} from "./authorization-routing.module";
|
||||
import {AuthorizeComponent} from './authorize/authorize.component';
|
||||
import {ReactiveFormsModule} from '@angular/forms';
|
||||
import {SharedModule} from "../shared/shared.module";
|
||||
import {NbSpinnerModule} from "@nebular/theme";
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
AuthorizeComponent
|
||||
],
|
||||
imports: [
|
||||
AuthorizationRoutingModule,
|
||||
CommonModule,
|
||||
SharedModule,
|
||||
ReactiveFormsModule,
|
||||
// Nebula
|
||||
NbSpinnerModule,
|
||||
]
|
||||
})
|
||||
export class AuthorizationModule {
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<nb-card>
|
||||
<nb-card-header>
|
||||
<h1>Authorize with your instance</h1>
|
||||
</nb-card-header>
|
||||
<nb-card-body>
|
||||
<div [formGroup]="serverForm">
|
||||
<input nbInput id="serverUrl" type="text" placeholder="mastodon.social" [formControlName]="'instance'">
|
||||
<button class="authorize-button" nbButton type="button"
|
||||
[nbSpinner]="authorizing"
|
||||
(click)="registerApplication()">Authorize
|
||||
</button>
|
||||
</div>
|
||||
<div *ngIf="isAuthorized.value">
|
||||
<p>User is authorized with {{currentInstance?.instanceName}}</p>
|
||||
<p>Next up: <a routerLink="/sync">Sync</a></p>
|
||||
</div>
|
||||
</nb-card-body>
|
||||
</nb-card>
|
||||
@@ -0,0 +1,9 @@
|
||||
.authorize-button {
|
||||
margin: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 573px) {
|
||||
.authorize-button {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { AuthorizeComponent } from './authorize.component';
|
||||
|
||||
describe('AuthorizeComponent', () => {
|
||||
let component: AuthorizeComponent;
|
||||
let fixture: ComponentFixture<AuthorizeComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [ AuthorizeComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(AuthorizeComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import {Component, OnDestroy, OnInit} from '@angular/core';
|
||||
import {FormControl, FormGroup} from "@angular/forms";
|
||||
import {ActivatedRoute, Params, Router} from "@angular/router";
|
||||
import {BehaviorSubject, filter, Subscription, take} from "rxjs";
|
||||
import {PersistentStore} from "../../shared/state/persistent/persistent-store.service";
|
||||
import {MastodonApiAccountsService, MastodonApiAuthenticationService, MastodonApiListsService} from "projects/mastodon-api/src/public-api";
|
||||
import {Instance} from "../../shared/state/persistent/state";
|
||||
|
||||
@Component({
|
||||
selector: 'app-authorize',
|
||||
templateUrl: './authorize.component.html',
|
||||
styleUrls: ['./authorize.component.scss']
|
||||
})
|
||||
export class AuthorizeComponent implements OnInit, OnDestroy {
|
||||
subscriptions: Subscription[] = [];
|
||||
currentInstance?: Instance;
|
||||
accessToken?: string;
|
||||
accountId?: string;
|
||||
isAuthorized: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
|
||||
authorizing: boolean = false;
|
||||
|
||||
serverForm = new FormGroup({
|
||||
instance: new FormControl(''),
|
||||
});
|
||||
|
||||
constructor(private mastodonApiAuthService: MastodonApiAuthenticationService,
|
||||
private store: PersistentStore,
|
||||
private mastodonApiAccountsService: MastodonApiAccountsService,
|
||||
private mastodonApiListsService: MastodonApiListsService,
|
||||
private router: Router,
|
||||
private route: ActivatedRoute,) {
|
||||
this.subscriptions.push(this.store
|
||||
.select('currentInstance')
|
||||
.subscribe((currentInstance) => {
|
||||
this.currentInstance = <Instance>currentInstance
|
||||
this.accessToken = this.currentInstance?.accessToken;
|
||||
this.accountId = this.currentInstance?.accountId;
|
||||
this.isAuthorized.next(!!(this.currentInstance && this.accessToken && this.accountId));
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
ngOnInit(): void {
|
||||
this.route.queryParams
|
||||
.pipe(
|
||||
filter((params: Params) => params['code']),
|
||||
take(1)
|
||||
)
|
||||
.subscribe(params => {
|
||||
if (this.currentInstance && !this.accessToken && params['code']) {
|
||||
this.mastodonApiAuthService
|
||||
.getAccessToken(this.currentInstance.instanceName, this.currentInstance.clientId, this.currentInstance.clientSecret, this.currentInstance.redirectUrl, params['code'])
|
||||
.pipe(take(1))
|
||||
.subscribe(accessToken => {
|
||||
this.store.set(
|
||||
'currentInstance',
|
||||
{...this.currentInstance, 'accessToken': accessToken}
|
||||
);
|
||||
this.mastodonApiAuthService.verifyCredentials(this.currentInstance!.instanceName, accessToken)
|
||||
.pipe(take(1))
|
||||
.subscribe((accountId) => {
|
||||
this.store.set(
|
||||
'currentInstance',
|
||||
{...this.currentInstance, 'accountId': accountId}
|
||||
);
|
||||
this.router.navigate(['/sync']);
|
||||
this.isAuthorized.next(true);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async registerApplication() {
|
||||
this.authorizing = true;
|
||||
this.subscriptions.push(this.store
|
||||
.select<Instance>('currentInstance')
|
||||
.subscribe((currentInstance) => {
|
||||
this.authorizing = false;
|
||||
let instanceName = this.serverForm.value.instance ?? '';
|
||||
if (instanceName === '') {
|
||||
return;
|
||||
}
|
||||
if (currentInstance?.instanceName === instanceName) {
|
||||
this.mastodonApiAuthService
|
||||
.authorizeUser(currentInstance.instanceName, currentInstance.clientId, currentInstance.redirectUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
// let redirectUrl = window.location.toString();
|
||||
let redirectUrl = window.location.protocol + '//' + window.location.host + window.location.pathname;
|
||||
this.authorizing = true;
|
||||
this.mastodonApiAuthService
|
||||
.createApp(instanceName, 'Mastolists', redirectUrl, 'https://mastolists.novaloop.cloud')
|
||||
.pipe(take(1))
|
||||
.subscribe(res => {
|
||||
this.authorizing = false;
|
||||
this.store.set(
|
||||
'currentInstance',
|
||||
<Instance>{
|
||||
id: res.id,
|
||||
instanceName: instanceName,
|
||||
appName: res.name,
|
||||
website: res.website,
|
||||
redirectUrl: res.redirectUri,
|
||||
clientId: res.clientId,
|
||||
clientSecret: res.clientSecret,
|
||||
}
|
||||
);
|
||||
this.mastodonApiAuthService.authorizeUser(instanceName, res.clientId, res.redirectUri);
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.subscriptions.forEach(subscription => subscription.unsubscribe());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import {RouterModule, Routes} from "@angular/router";
|
||||
import {NgModule} from "@angular/core";
|
||||
import {MatrixComponent} from "./matrix/matrix.component";
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: MatrixComponent,
|
||||
}
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class FollowingsMatrixRoutingModule {
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import {NgModule} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {MatrixComponent} from './matrix/matrix.component';
|
||||
import {FollowingsMatrixRoutingModule} from './followings-matrix-routing.module';
|
||||
import {SharedModule} from "../shared/shared.module";
|
||||
import {NbBadgeModule, NbCheckboxModule, NbPopoverModule} from "@nebular/theme";
|
||||
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
MatrixComponent
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
SharedModule,
|
||||
FollowingsMatrixRoutingModule,
|
||||
// Nebular
|
||||
NbCheckboxModule,
|
||||
NbBadgeModule,
|
||||
NbPopoverModule,
|
||||
]
|
||||
|
||||
})
|
||||
export class FollowingsMatrixModule {
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<nb-card>
|
||||
<nb-card-header style="position: relative;">
|
||||
<h1>Matrix View for Followings</h1>
|
||||
<div class="divider"></div>
|
||||
<nb-badge text="experimental" position="top right" status="success"></nb-badge>
|
||||
<app-filters></app-filters>
|
||||
</nb-card-header>
|
||||
<nb-card-body>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th *ngFor="let list of lists$ | async" class="list-title">
|
||||
{{list.title}}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let account of followings$ | async">
|
||||
<th>
|
||||
<ng-template #templateRef>
|
||||
<div style="padding: 20px;">
|
||||
<p [innerHTML]="account.note"></p>
|
||||
<h4 *ngIf="account.fields.length > 0">Custom fields</h4>
|
||||
<div class="table">
|
||||
<div class="row" *ngFor="let field of account.fields">
|
||||
<div class="column first">{{ field.name }}</div>
|
||||
<div class="column" [innerHTML]="field.value"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
<span (click)="openMoreInfo(account)"
|
||||
[nbPopover]="templateRef"
|
||||
nbPopoverTrigger="hover"
|
||||
nbPopoverPlacement="bottom"
|
||||
>
|
||||
@{{account.username}}
|
||||
</span>
|
||||
</th>
|
||||
<td *ngFor="let list of lists$ | async">
|
||||
<nb-checkbox
|
||||
[checked]="isChecked(account.lists, list.id)"
|
||||
(checkedChange)="onCheckedChange($event, account.id, list.id)">
|
||||
</nb-checkbox>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</nb-card-body>
|
||||
</nb-card>
|
||||
|
||||
<ng-template #dialog let-ref="dialogRef">
|
||||
<div class="container" *ngIf="selectedAccount">
|
||||
<app-account [account]="selectedAccount">
|
||||
<div footer>
|
||||
<button nbButton style="cursor: pointer;" (click)="ref.close()">Close Infos</button>
|
||||
</div>
|
||||
</app-account>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
table {
|
||||
display: block;
|
||||
height: max(600px, calc(100vh - 300px));
|
||||
width: 1080px;
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
border: none;
|
||||
|
||||
td {
|
||||
border: none;
|
||||
}
|
||||
|
||||
thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background-color: #222b45;
|
||||
z-index: 2;
|
||||
border: none;
|
||||
}
|
||||
|
||||
thead th.list-title {
|
||||
writing-mode: vertical-lr;
|
||||
transform: rotate(180deg);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
tbody th {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
background-color: #222b45;
|
||||
border: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
line-height: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
nb-card.more-info-panel {
|
||||
min-width: 450px;
|
||||
max-width: 800px;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { MatrixComponent } from './matrix.component';
|
||||
|
||||
describe('MatrixComponent', () => {
|
||||
let component: MatrixComponent;
|
||||
let fixture: ComponentFixture<MatrixComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [ MatrixComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(MatrixComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import {Component, TemplateRef, ViewChild} from '@angular/core';
|
||||
import {PersistentStore} from "../../shared/state/persistent/persistent-store.service";
|
||||
import {Account} from "../../../../../mastodon-api/src/lib/interfaces/public/account";
|
||||
import {List} from "../../../../../mastodon-api/src/lib/interfaces/public/list";
|
||||
import {ListService} from "../../shared/services/list.service";
|
||||
import {Observable} from "rxjs";
|
||||
import {select, Store} from "@ngrx/store";
|
||||
import {selectFilteredFollowingsWithLists, selectLists} from "../../shared/state/store/selectors";
|
||||
import {ListActions} from "../../shared/state/store/actions";
|
||||
import {NbDialogService} from "@nebular/theme";
|
||||
|
||||
@Component({
|
||||
selector: 'app-matrix',
|
||||
templateUrl: './matrix.component.html',
|
||||
styleUrls: ['./matrix.component.scss']
|
||||
})
|
||||
export class MatrixComponent {
|
||||
@ViewChild('dialog', {static: true}) dialog: TemplateRef<any> | undefined;
|
||||
followings$: Observable<ReadonlyArray<Account>>;
|
||||
lists$: Observable<ReadonlyArray<List>>;
|
||||
selectedAccount: Account | undefined;
|
||||
|
||||
constructor(private store: Store, private dialogService: NbDialogService) {
|
||||
this.followings$ = this.store.pipe(select(selectFilteredFollowingsWithLists));
|
||||
this.lists$ = this.store.pipe(select(selectLists));
|
||||
}
|
||||
|
||||
openMoreInfo(account: Account) {
|
||||
this.selectedAccount = account;
|
||||
this.dialogService.open(this.dialog!);
|
||||
}
|
||||
|
||||
isChecked(lists: List[], id: string): boolean {
|
||||
return lists.some(list => list.id === id);
|
||||
}
|
||||
|
||||
onCheckedChange($event: boolean, accountId: string, listId: string) {
|
||||
if ($event) {
|
||||
this.store.dispatch(ListActions.addAccountToList({accountId, listId}));
|
||||
} else {
|
||||
this.store.dispatch(ListActions.removeAccountFromList({accountId, listId}));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import {RouterModule, Routes} from "@angular/router";
|
||||
import {NgModule} from "@angular/core";
|
||||
import {FollowingsComponent} from "./followings/followings.component";
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: FollowingsComponent
|
||||
}
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class FollowingsRoutingModule {
|
||||
}
|
||||
23
projects/mastolists/src/app/followings/followings.module.ts
Normal file
23
projects/mastolists/src/app/followings/followings.module.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import {NgModule} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {FollowingsComponent} from './followings/followings.component';
|
||||
import {SharedModule} from '../shared/shared.module';
|
||||
import {FollowingsRoutingModule} from './followings-routing.module';
|
||||
import {NbListModule, NbToggleModule} from "@nebular/theme";
|
||||
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
FollowingsComponent
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
SharedModule,
|
||||
FollowingsRoutingModule,
|
||||
// Nebula
|
||||
NbListModule,
|
||||
NbToggleModule,
|
||||
]
|
||||
})
|
||||
export class FollowingsModule {
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<nb-card>
|
||||
<nb-card-header>
|
||||
<h1>Followings</h1>
|
||||
<div class="divider"></div>
|
||||
<app-filters></app-filters>
|
||||
</nb-card-header>
|
||||
<nb-card-body>
|
||||
<nb-list>
|
||||
<nb-list-item *ngFor="let following of followings$ | async">
|
||||
<app-account [account]="following">
|
||||
<div body>
|
||||
<div *ngIf="following.lists.length > 0">
|
||||
<h4>Lists</h4>
|
||||
<ul>
|
||||
<li *ngFor="let list of following.lists">
|
||||
<a [routerLink]="['/lists']" [queryParams]="{listId: list.id}">{{list.title}}</a>
|
||||
<nb-icon icon="trash-2-outline" class="nb-icon-remove"
|
||||
(click)="removeAccountFromList(following.id, list.id)"></nb-icon>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div footer>
|
||||
<select class="list-select" #listSelect>
|
||||
<option *ngFor="let list of lists$ | async" [value]="list.id">{{list.title}}</option>
|
||||
</select>
|
||||
<button class="add-to-list-button" nbButton
|
||||
status="basic"
|
||||
(click)="addAccountToSelectedList(following.id, listSelect)">Add to list
|
||||
</button>
|
||||
</div>
|
||||
</app-account>
|
||||
</nb-list-item>
|
||||
</nb-list>
|
||||
</nb-card-body>
|
||||
</nb-card>
|
||||
@@ -0,0 +1,22 @@
|
||||
select.list-select {
|
||||
height: 40px;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
nb-icon.nb-icon-remove {
|
||||
margin-left: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.add-to-list-button {
|
||||
margin-left: 20px;
|
||||
margin-top: -3px;
|
||||
}
|
||||
|
||||
@media (max-width: 573px) {
|
||||
.add-to-list-button {
|
||||
margin-top: 20px;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { FollowingsComponent } from './followings.component';
|
||||
|
||||
describe('FollowingsComponent', () => {
|
||||
let component: FollowingsComponent;
|
||||
let fixture: ComponentFixture<FollowingsComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [ FollowingsComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(FollowingsComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import {Component} from '@angular/core';
|
||||
import {PersistentStore} from "../../shared/state/persistent/persistent-store.service";
|
||||
import {Account, List, MastodonApiListsService} from 'projects/mastodon-api/src/public-api';
|
||||
import {NbToastrService} from "@nebular/theme";
|
||||
import {ListService} from "../../shared/services/list.service";
|
||||
import {Observable} from "rxjs";
|
||||
import {select, Store} from "@ngrx/store";
|
||||
import {selectFilteredFollowingsWithLists, selectFollowings, selectFollowingsWithLists, selectLists} from "../../shared/state/store/selectors";
|
||||
import {FiltersActions, ListActions, MastodonApiActions} from "../../shared/state/store/actions";
|
||||
|
||||
@Component({
|
||||
selector: 'app-followings',
|
||||
templateUrl: './followings.component.html',
|
||||
styleUrls: ['./followings.component.scss']
|
||||
})
|
||||
export class FollowingsComponent {
|
||||
followings$: Observable<ReadonlyArray<Account>>;
|
||||
lists$: Observable<ReadonlyArray<List>>;
|
||||
|
||||
constructor(private store: Store) {
|
||||
this.followings$ = this.store.pipe(select(selectFilteredFollowingsWithLists));
|
||||
this.lists$ = this.store.pipe(select(selectLists));
|
||||
}
|
||||
|
||||
addAccountToSelectedList(accountId: string, select: HTMLSelectElement) {
|
||||
this.store.dispatch(ListActions.addAccountToList({accountId, listId: select.value}));
|
||||
}
|
||||
|
||||
removeAccountFromList(accountId: string, listId: string) {
|
||||
this.store.dispatch(ListActions.removeAccountFromList({accountId, listId}));
|
||||
}
|
||||
|
||||
}
|
||||
17
projects/mastolists/src/app/home/home-routing.module.ts
Normal file
17
projects/mastolists/src/app/home/home-routing.module.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import {RouterModule, Routes} from "@angular/router";
|
||||
import {NgModule} from "@angular/core";
|
||||
import {HomeComponent} from "./home/home.component";
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: HomeComponent
|
||||
}
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class HomeRoutingModule {
|
||||
}
|
||||
19
projects/mastolists/src/app/home/home.module.ts
Normal file
19
projects/mastolists/src/app/home/home.module.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import {NgModule} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {HomeComponent} from "./home/home.component";
|
||||
import {SharedModule} from "../shared/shared.module";
|
||||
import {HomeRoutingModule} from "./home-routing.module";
|
||||
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
HomeComponent,
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
SharedModule,
|
||||
HomeRoutingModule,
|
||||
]
|
||||
})
|
||||
export class HomeModule {
|
||||
}
|
||||
15
projects/mastolists/src/app/home/home/home.component.html
Normal file
15
projects/mastolists/src/app/home/home/home.component.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<nb-card>
|
||||
<nb-card-header>
|
||||
<h1>Welcome to Mastolists</h1>
|
||||
</nb-card-header>
|
||||
<nb-card-body>
|
||||
<p>Organize your Followings into lists.</p>
|
||||
|
||||
<ul>
|
||||
<li><a [routerLink]="['/auth']">Authorize</a> with your instance</li>
|
||||
<li><a [routerLink]="['/sync']">Sync</a> your lists and followings to local storage</li>
|
||||
<li>Select the <a [routerLink]="['/followings']">List view</a> to add and remove users from lists</li>
|
||||
<li>... or use the experimental <a [routerLink]="['/matrix']">Matrix view</a></li>
|
||||
</ul>
|
||||
</nb-card-body>
|
||||
</nb-card>
|
||||
23
projects/mastolists/src/app/home/home/home.component.spec.ts
Normal file
23
projects/mastolists/src/app/home/home/home.component.spec.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { HomeComponent } from './home.component';
|
||||
|
||||
describe('HomeComponent', () => {
|
||||
let component: HomeComponent;
|
||||
let fixture: ComponentFixture<HomeComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [ HomeComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(HomeComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
10
projects/mastolists/src/app/home/home/home.component.ts
Normal file
10
projects/mastolists/src/app/home/home/home.component.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-home',
|
||||
templateUrl: './home.component.html',
|
||||
styleUrls: ['./home.component.scss']
|
||||
})
|
||||
export class HomeComponent {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<nb-card accent="info" class="account-card">
|
||||
<nb-card-header>
|
||||
<a [href]="account.url">
|
||||
<nb-user
|
||||
size="medium"
|
||||
shape="semi-round"
|
||||
[name]="account.displayName"
|
||||
[title]="'@' + account.username"
|
||||
[picture]="account.avatar"
|
||||
>
|
||||
</nb-user>
|
||||
</a>
|
||||
<ng-content select="'[header]'"></ng-content>
|
||||
</nb-card-header>
|
||||
<nb-card-body>
|
||||
<p [innerHTML]="account.note"></p>
|
||||
<h4 *ngIf="account.fields.length > 0">Custom fields</h4>
|
||||
<div class="table">
|
||||
<div class="row" *ngFor="let field of account.fields">
|
||||
<div class="column first">{{ field.name }}</div>
|
||||
<div class="column" [innerHTML]="field.value"></div>
|
||||
</div>
|
||||
</div>
|
||||
<ng-content select="'[body]'"></ng-content>
|
||||
</nb-card-body>
|
||||
<nb-card-footer>
|
||||
<ng-content select="'[footer]'"></ng-content>
|
||||
</nb-card-footer>
|
||||
</nb-card>
|
||||
@@ -0,0 +1,29 @@
|
||||
:host {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
td.field-label {
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
min-height: 30px;
|
||||
|
||||
.first {
|
||||
min-width: 160px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 573px) {
|
||||
.row {
|
||||
flex-direction: column;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { AccountComponent } from './account.component';
|
||||
|
||||
describe('AccountComponent', () => {
|
||||
let component: AccountComponent;
|
||||
let fixture: ComponentFixture<AccountComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [ AccountComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(AccountComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import {Component, Input} from '@angular/core';
|
||||
import {Account} from 'projects/mastodon-api/src/public-api';
|
||||
|
||||
@Component({
|
||||
selector: 'app-account',
|
||||
templateUrl: './account.component.html',
|
||||
styleUrls: ['./account.component.scss']
|
||||
})
|
||||
export class AccountComponent {
|
||||
|
||||
@Input() account!: Account;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<div class="container">
|
||||
<div class="form">
|
||||
<form [formGroup]="filtersForm">
|
||||
<input nbInput id="text" type="text"
|
||||
placeholder="@username or free text"
|
||||
[formControlName]="'text'">
|
||||
<nb-select multiple placeholder="Multiple Select" [formControlName]="'lists'">
|
||||
<nb-option *ngFor="let list of lists$ | async" [value]="list.id">{{list.title}}</nb-option>
|
||||
</nb-select>
|
||||
</form>
|
||||
<div class="nb-toggle-container">
|
||||
<nb-toggle
|
||||
[checked]="(filters$ | async)?.unlisted"
|
||||
(checkedChange)="toggleUnlisted($event)"
|
||||
>Unlisted only
|
||||
</nb-toggle>
|
||||
</div>
|
||||
<button nbButton (click)="clearFilter()">Clear Filter</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,54 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
|
||||
input {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
nb-select {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.nb-toggle-container {
|
||||
padding-top: 2px;
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.buttons {
|
||||
padding-top: 20px;
|
||||
|
||||
button {
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 573px) {
|
||||
.form {
|
||||
flex-direction: column;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
input {
|
||||
margin-top: 10px;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
nb-select {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.nb-toggle-container {
|
||||
margin-top: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { FiltersComponent } from './filters.component';
|
||||
|
||||
describe('FiltersComponent', () => {
|
||||
let component: FiltersComponent;
|
||||
let fixture: ComponentFixture<FiltersComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [ FiltersComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(FiltersComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import {Component, OnDestroy} from '@angular/core';
|
||||
import {FormControl, FormGroup} from "@angular/forms";
|
||||
import {select, Store} from "@ngrx/store";
|
||||
import {FiltersActions, MastodonApiActions} from "../../state/store/actions";
|
||||
import {debounceTime, Observable, Subscription, tap} from "rxjs";
|
||||
import {List} from 'projects/mastodon-api/src/public-api';
|
||||
import {selectFilters, selectFiltersForForm, selectLists} from "../../state/store/selectors";
|
||||
import {Filters} from "../../state/store/reducers";
|
||||
|
||||
interface FiltersForForm {
|
||||
fullText: string | undefined;
|
||||
lists: ReadonlyArray<string>;
|
||||
unlisted: boolean;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-filters',
|
||||
templateUrl: './filters.component.html',
|
||||
styleUrls: ['./filters.component.scss']
|
||||
})
|
||||
export class FiltersComponent implements OnDestroy {
|
||||
filtersForm = new FormGroup({
|
||||
text: new FormControl(),
|
||||
lists: new FormControl([] as string[]),
|
||||
});
|
||||
|
||||
lists$: Observable<ReadonlyArray<List>>;
|
||||
filters$: Observable<FiltersForForm>;
|
||||
formSubscription: Subscription;
|
||||
|
||||
constructor(private store: Store) {
|
||||
this.lists$ = this.store.pipe(select(selectLists));
|
||||
this.filters$ = this.store.pipe(
|
||||
select(selectFiltersForForm),
|
||||
tap(filters => {
|
||||
this.filtersForm.patchValue({
|
||||
text: filters.fullText,
|
||||
lists: filters.lists as string[],
|
||||
}, {emitEvent: false, onlySelf: true});
|
||||
}),
|
||||
);
|
||||
this.formSubscription = this.filtersForm.valueChanges
|
||||
.pipe(debounceTime(500))
|
||||
.subscribe(value => {
|
||||
if (value['lists'] && value['lists'].length > 0) {
|
||||
this.store.dispatch(FiltersActions.setLists({lists: value.lists}));
|
||||
this.store.dispatch(FiltersActions.setUnlisted({unlisted: false}));
|
||||
}
|
||||
if (!value['lists'] || value['lists'].length === 0) {
|
||||
this.store.dispatch(FiltersActions.setLists({lists: []}));
|
||||
}
|
||||
if (value['text']) {
|
||||
const text = value['text'];
|
||||
if (text.length > 0) {
|
||||
const parts = text.split(/[\s,]+/);
|
||||
if (parts.some((part: string) => part.startsWith('@'))) {
|
||||
const username = parts.find((part: string) => part.startsWith('@'));
|
||||
this.store.dispatch(FiltersActions.setUsername({username: username.substring(1)}));
|
||||
} else {
|
||||
this.store.dispatch(FiltersActions.setUsername({username: ''}));
|
||||
}
|
||||
this.store.dispatch(FiltersActions.setFreeText({freeText: parts.filter((part: string) => !part.startsWith('@'))}));
|
||||
} else {
|
||||
this.store.dispatch(FiltersActions.setUsername({username: ''}));
|
||||
this.store.dispatch(FiltersActions.setFreeText({freeText: []}));
|
||||
}
|
||||
} else {
|
||||
this.store.dispatch(FiltersActions.setUsername({username: ''}));
|
||||
this.store.dispatch(FiltersActions.setFreeText({freeText: []}));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.formSubscription.unsubscribe();
|
||||
}
|
||||
|
||||
clearFilter() {
|
||||
this.store.dispatch(FiltersActions.clearFilters());
|
||||
}
|
||||
|
||||
toggleUnlisted($event: boolean) {
|
||||
this.store.dispatch(FiltersActions.setUnlisted({unlisted: $event}));
|
||||
}
|
||||
}
|
||||
34
projects/mastolists/src/app/shared/guards/auth.guard.ts
Normal file
34
projects/mastolists/src/app/shared/guards/auth.guard.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import {Injectable} from '@angular/core';
|
||||
import {MastodonApiAuthenticationService} from "../../../../../mastodon-api/src/lib/services/mastodon-api-authentication.service";
|
||||
import {ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot} from "@angular/router";
|
||||
import {map, Observable, tap} from "rxjs";
|
||||
import {PersistentStore} from "../state/persistent/persistent-store.service";
|
||||
import {Store} from "@ngrx/store";
|
||||
|
||||
@Injectable({providedIn: 'root'})
|
||||
export class AuthGuard implements CanActivate {
|
||||
|
||||
constructor(private persistentStore: PersistentStore,
|
||||
private store: Store,
|
||||
private router: Router,
|
||||
private mastodonApiAuthService: MastodonApiAuthenticationService) {
|
||||
}
|
||||
|
||||
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> | boolean {
|
||||
const currentInstance = this.persistentStore.value.currentInstance;
|
||||
if (currentInstance && currentInstance.accessToken && currentInstance.accountId) return true;
|
||||
if (currentInstance && currentInstance.accessToken && !currentInstance.accountId) {
|
||||
return this.mastodonApiAuthService.verifyCredentials(currentInstance.instanceName, currentInstance.accessToken).pipe(
|
||||
map((accountId) => {
|
||||
this.persistentStore.set(
|
||||
'currentInstance',
|
||||
{...currentInstance, 'accountId': accountId}
|
||||
);
|
||||
return !!accountId;
|
||||
})
|
||||
);
|
||||
}
|
||||
this.router.navigate(['/auth']);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { AccountService } from './account.service';
|
||||
|
||||
describe('AccountService', () => {
|
||||
let service: AccountService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(AccountService);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import {Injectable} from '@angular/core';
|
||||
import {MastodonApiAccountsService} from "../../../../../mastodon-api/src/lib/services/mastodon-api-accounts.service";
|
||||
import {PersistentStore} from "../state/persistent/persistent-store.service";
|
||||
import {EMPTY, expand, map, Observable, reduce} from "rxjs";
|
||||
import {Account} from "../../../../../mastodon-api/src/lib/interfaces/public/account";
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class AccountService {
|
||||
|
||||
constructor(private mastodonApiAccountsService: MastodonApiAccountsService,
|
||||
private store: PersistentStore) {
|
||||
|
||||
}
|
||||
|
||||
loadFollowings(): Observable<ReadonlyArray<Account>> {
|
||||
const applicationState = this.store.value;
|
||||
const instanceName = applicationState.currentInstance?.instanceName;
|
||||
const accessToken = applicationState.currentInstance?.accessToken;
|
||||
const accountId = applicationState.currentInstance?.accountId;
|
||||
return this.mastodonApiAccountsService
|
||||
.getFollowingsForAccount(instanceName, accessToken!, accountId)
|
||||
.pipe(
|
||||
expand(result => {
|
||||
const nextLink = result[0];
|
||||
if (nextLink && nextLink.length > 0) {
|
||||
return this.mastodonApiAccountsService.getFollowingsForAccount(instanceName, accessToken!, accountId, nextLink);
|
||||
}
|
||||
return EMPTY;
|
||||
}),
|
||||
map(result => result[1]),
|
||||
reduce((acc: Account[], res: Account[]) => {
|
||||
return acc.concat(res);
|
||||
}, []),
|
||||
map(accounts => accounts.sort((a, b) => a.username.localeCompare(b.username)) as ReadonlyArray<Account>),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { ListService } from './list.service';
|
||||
|
||||
describe('ListService', () => {
|
||||
let service: ListService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(ListService);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
||||
79
projects/mastolists/src/app/shared/services/list.service.ts
Normal file
79
projects/mastolists/src/app/shared/services/list.service.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import {Injectable} from '@angular/core';
|
||||
import {EMPTY, expand, map, Observable, reduce, take} from "rxjs";
|
||||
import {MastodonApiListsService} from "../../../../../mastodon-api/src/lib/services/mastodon-api-lists.service";
|
||||
import {PersistentStore} from "../state/persistent/persistent-store.service";
|
||||
import {Account} from "../../../../../mastodon-api/src/lib/interfaces/public/account";
|
||||
import {List} from "../../../../../mastodon-api/src/lib/interfaces/public/list";
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ListService {
|
||||
|
||||
constructor(private mastodonApiListsService: MastodonApiListsService,
|
||||
private store: PersistentStore) {
|
||||
}
|
||||
|
||||
loadLists(): Observable<List[]> {
|
||||
const applicationState = this.store.value;
|
||||
const instanceName = applicationState.currentInstance?.instanceName;
|
||||
const accessToken = applicationState.currentInstance?.accessToken;
|
||||
return this.mastodonApiListsService
|
||||
.getLists(instanceName, accessToken!)
|
||||
.pipe(map(result => result.sort((a, b) => a.title.localeCompare(b.title))));
|
||||
}
|
||||
|
||||
loadAccountsIdsForList(listId: string): Observable<{ [id: string]: string[] }> {
|
||||
const applicationState = this.store.value;
|
||||
const instanceName = applicationState.currentInstance?.instanceName;
|
||||
const accessToken = applicationState.currentInstance?.accessToken;
|
||||
return this.mastodonApiListsService
|
||||
.getAccountsForList(instanceName, accessToken!, listId)
|
||||
.pipe(
|
||||
expand(result => {
|
||||
const nextLink = result[0];
|
||||
if (nextLink && nextLink.length > 0) {
|
||||
return this.mastodonApiListsService.getAccountsForList(instanceName, accessToken!, listId, nextLink);
|
||||
}
|
||||
return EMPTY;
|
||||
}),
|
||||
map(result => {
|
||||
const accounts = result[1];
|
||||
return {[listId]: accounts.map(account => account.id)};
|
||||
}
|
||||
),
|
||||
reduce((acc: { [listId: string]: string[] }, res: { [listId: string]: string[] }) => {
|
||||
const listId = Object.keys(res)[0];
|
||||
if (acc[listId] !== undefined && acc[listId].length > 0) {
|
||||
acc[listId] = acc[listId].concat(res[listId]);
|
||||
return acc;
|
||||
}
|
||||
return {...acc, ...res};
|
||||
}, {}),
|
||||
);
|
||||
}
|
||||
|
||||
addAccountToSelectedList(accountId: string, listId: string) {
|
||||
const applicationState = this.store.value;
|
||||
const instanceName = applicationState.currentInstance?.instanceName;
|
||||
const accessToken = applicationState.currentInstance?.accessToken;
|
||||
return this.mastodonApiListsService
|
||||
.addAccountToList(instanceName, accessToken!, listId, accountId)
|
||||
.pipe(
|
||||
take(1),
|
||||
);
|
||||
}
|
||||
|
||||
removeAccountFromList(accountId: string, listId: string) {
|
||||
const applicationState = this.store.value;
|
||||
const instanceName = applicationState.currentInstance?.instanceName;
|
||||
const accessToken = applicationState.currentInstance?.accessToken;
|
||||
return this.mastodonApiListsService
|
||||
.removeAccountFromList(instanceName, accessToken!, listId, accountId)
|
||||
.pipe(
|
||||
take(1),
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
54
projects/mastolists/src/app/shared/shared.module.ts
Normal file
54
projects/mastolists/src/app/shared/shared.module.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import {NgModule} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {AccountComponent} from './components/account/account.component';
|
||||
import {
|
||||
NbActionsModule,
|
||||
NbButtonModule,
|
||||
NbCardModule,
|
||||
NbIconModule,
|
||||
NbInputModule,
|
||||
NbSelectModule,
|
||||
NbSpinnerModule,
|
||||
NbToggleModule,
|
||||
NbUserModule
|
||||
} from "@nebular/theme";
|
||||
import {FiltersComponent} from './components/filters/filters.component';
|
||||
import {ReactiveFormsModule} from "@angular/forms";
|
||||
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
AccountComponent,
|
||||
FiltersComponent,
|
||||
],
|
||||
providers: [],
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
// Nebula import only
|
||||
NbInputModule,
|
||||
NbButtonModule,
|
||||
NbSpinnerModule,
|
||||
NbSelectModule,
|
||||
NbToggleModule,
|
||||
// Nebula
|
||||
NbCardModule,
|
||||
NbUserModule,
|
||||
NbActionsModule,
|
||||
NbIconModule,
|
||||
NbButtonModule,
|
||||
NbInputModule,
|
||||
],
|
||||
exports: [
|
||||
AccountComponent,
|
||||
FiltersComponent,
|
||||
// Nebula
|
||||
NbCardModule,
|
||||
NbActionsModule,
|
||||
NbIconModule,
|
||||
NbButtonModule,
|
||||
NbInputModule,
|
||||
]
|
||||
})
|
||||
export class SharedModule {
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import {Injectable} from "@angular/core";
|
||||
|
||||
function getLocalStorage(): Storage {
|
||||
return localStorage;
|
||||
}
|
||||
|
||||
@Injectable({providedIn: "root"})
|
||||
export class LocalStorageRefService {
|
||||
get localStorage(): Storage {
|
||||
return getLocalStorage();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import {BehaviorSubject, distinctUntilChanged, map, Observable} from "rxjs";
|
||||
import {Injectable} from "@angular/core";
|
||||
import {LocalStorageRefService} from "./local-storage-ref.service";
|
||||
import {Instance, State} from "./state";
|
||||
|
||||
const state = <State>{};
|
||||
|
||||
@Injectable({providedIn: 'root'})
|
||||
export class PersistentStore {
|
||||
private _localStorage: Storage;
|
||||
private subject = new BehaviorSubject<State>(state);
|
||||
private store = this.subject.asObservable().pipe(distinctUntilChanged());
|
||||
|
||||
constructor(private _localStorageRefService: LocalStorageRefService) {
|
||||
this._localStorage = _localStorageRefService.localStorage;
|
||||
this.loadState();
|
||||
}
|
||||
|
||||
get value() {
|
||||
return this.subject.value;
|
||||
}
|
||||
|
||||
isAuthorized(): boolean {
|
||||
const currentInstance = this.value.currentInstance;
|
||||
if (!currentInstance) return false;
|
||||
if (!currentInstance.accessToken) return false;
|
||||
if (!currentInstance.accountId) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
select<T>(name: string): Observable<T> {
|
||||
return this.store.pipe(map(value => value ? <T>value[name as keyof State] : <T>{}));
|
||||
}
|
||||
|
||||
set(name: string, state: any) {
|
||||
this.subject.next({
|
||||
...this.value, [name as keyof State]: state
|
||||
});
|
||||
this.persistState();
|
||||
}
|
||||
|
||||
private persistState() {
|
||||
const jsonData = JSON.stringify(this.subject.value, this.stringifyMap);
|
||||
this._localStorage.setItem('applicationData', jsonData);
|
||||
}
|
||||
|
||||
private loadState() {
|
||||
const jsonData = this._localStorage.getItem('applicationData');
|
||||
if (jsonData) {
|
||||
const data = JSON.parse(jsonData, this.parseMap);
|
||||
this.subject.next(data);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private stringifyMap(key: string, value: Map<string, Instance> | Map<string, string> | object) {
|
||||
if (value instanceof Map) {
|
||||
return {
|
||||
dataType: 'Map',
|
||||
value: [...value]
|
||||
};
|
||||
} else {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
private parseMap(key: string, value: any) {
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
if (value.dataType === 'Map') {
|
||||
return new Map(value.value);
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
19
projects/mastolists/src/app/shared/state/persistent/state.ts
Normal file
19
projects/mastolists/src/app/shared/state/persistent/state.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import {Account} from "../../../../../../mastodon-api/src/lib/interfaces/public/account";
|
||||
import {List} from "../../../../../../mastodon-api/src/lib/interfaces/public/list";
|
||||
|
||||
export interface Instance {
|
||||
instanceName: string;
|
||||
accessToken?: string;
|
||||
accountId: string;
|
||||
id: string;
|
||||
appName: string;
|
||||
website: string;
|
||||
redirectUrl: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
vapidKey: string;
|
||||
}
|
||||
|
||||
export interface State {
|
||||
currentInstance: Instance;
|
||||
}
|
||||
40
projects/mastolists/src/app/shared/state/store/actions.ts
Normal file
40
projects/mastolists/src/app/shared/state/store/actions.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import {createAction, createActionGroup, emptyProps, props} from "@ngrx/store";
|
||||
import {List} from "../../../../../../mastodon-api/src/lib/interfaces/public/list";
|
||||
import {Account} from "../../../../../../mastodon-api/src/lib/interfaces/public/account";
|
||||
|
||||
export const MastodonApiActions = createActionGroup({
|
||||
source: 'Mastodon API',
|
||||
events: {
|
||||
'Load Lists': emptyProps(),
|
||||
'Load Followings': emptyProps(),
|
||||
'Load Accounts For List': emptyProps(),
|
||||
'Lists Loaded': props<{ lists: ReadonlyArray<List> }>(),
|
||||
'Lists Loaded Error': emptyProps(),
|
||||
'Account Ids for Lists Loaded': props<{ mappings: { [listId: string]: string[] } }>(),
|
||||
'Followings Loaded': props<{ followings: ReadonlyArray<Account> }>(),
|
||||
'Followings Loaded Error': props<{ error: string }>(),
|
||||
}
|
||||
});
|
||||
|
||||
export const ListActions = createActionGroup({
|
||||
source: 'List',
|
||||
events: {
|
||||
'Add Account to List': props<{ accountId: string, listId: string }>(),
|
||||
'Add Account to List Success': emptyProps(),
|
||||
'Add Account to List Error': emptyProps(),
|
||||
'Remove Account from List': props<{ accountId: string, listId: string }>(),
|
||||
'Remove Account from List Success': emptyProps(),
|
||||
'Remove Account from List Error': emptyProps(),
|
||||
},
|
||||
});
|
||||
|
||||
export const FiltersActions = createActionGroup({
|
||||
source: 'Filters',
|
||||
events: {
|
||||
'Set Username': props<{ username: string }>(),
|
||||
'Set Free Text': props<{ freeText: ReadonlyArray<string> }>(),
|
||||
'Set Lists': props<{ lists: ReadonlyArray<string> }>(),
|
||||
'Set Unlisted': props<{ unlisted: boolean }>(),
|
||||
'Clear Filters': emptyProps(),
|
||||
},
|
||||
})
|
||||
108
projects/mastolists/src/app/shared/state/store/effects.ts
Normal file
108
projects/mastolists/src/app/shared/state/store/effects.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import {Injectable} from "@angular/core";
|
||||
import {act, Actions, createEffect, ofType} from "@ngrx/effects";
|
||||
import {ListActions, MastodonApiActions} from "./actions";
|
||||
import {catchError, concat, exhaustMap, forkJoin, map, merge, of, switchMap} from "rxjs";
|
||||
import {ListService} from "../../services/list.service";
|
||||
import {AccountService} from "../../services/account.service";
|
||||
import {NbToastrService} from "@nebular/theme";
|
||||
|
||||
@Injectable()
|
||||
export class ApplicationEffects {
|
||||
loadLists$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(MastodonApiActions.loadLists),
|
||||
exhaustMap(() => this.listService.loadLists().pipe(
|
||||
map(lists => MastodonApiActions.listsLoaded({lists})),
|
||||
catchError(() => of(MastodonApiActions.listsLoadedError())),
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
listsLoaded$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(MastodonApiActions.listsLoaded),
|
||||
switchMap((action) => {
|
||||
return forkJoin(action.lists.map(list => this.listService.loadAccountsIdsForList(list.id)))
|
||||
.pipe(
|
||||
map(mappings => {
|
||||
const accountsInList: { [id: string]: string[] } = mappings.reduce((acc, mapping) => {
|
||||
return {...acc, ...mapping};
|
||||
}, {});
|
||||
return MastodonApiActions.accountIdsForListsLoaded({mappings: accountsInList})
|
||||
}
|
||||
),
|
||||
);
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
loadFollowings$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(MastodonApiActions.loadFollowings),
|
||||
exhaustMap(() => this.accountService.loadFollowings().pipe(
|
||||
map(followings => MastodonApiActions.followingsLoaded({followings})),
|
||||
catchError((error) => of(MastodonApiActions.followingsLoadedError({error}))),
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
addAccountToList$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(ListActions.addAccountToList),
|
||||
exhaustMap((action) => this.listService.addAccountToSelectedList(action.accountId, action.listId).pipe(
|
||||
map(() => ListActions.addAccountToListSuccess()),
|
||||
catchError(() => of(ListActions.addAccountToListError())),
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
addAccountToListSuccess$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(ListActions.addAccountToListSuccess),
|
||||
map(() => this.toastService.success('Account added to list', 'Success')),
|
||||
), {dispatch: false}
|
||||
);
|
||||
|
||||
addCountsToListError$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(ListActions.addAccountToListError),
|
||||
map(() => this.toastService.danger('Could not add account to list, please reload', 'Error')),
|
||||
), {dispatch: false}
|
||||
);
|
||||
|
||||
removeAccountFromList$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(ListActions.removeAccountFromList),
|
||||
exhaustMap((action) => this.listService.removeAccountFromList(action.accountId, action.listId).pipe(
|
||||
map(() => ListActions.removeAccountFromListSuccess()),
|
||||
catchError(() => of(ListActions.removeAccountFromListError())),
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
removeAccountFromListSuccess$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(ListActions.removeAccountFromListSuccess),
|
||||
map(() => this.toastService.success('Account removed from list', 'Success')),
|
||||
), {dispatch: false}
|
||||
);
|
||||
|
||||
removeAccountFromListError$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(ListActions.removeAccountFromListError),
|
||||
map(() => this.toastService.danger('Could not remove account from list, please reload', 'Error')),
|
||||
), {dispatch: false}
|
||||
);
|
||||
|
||||
constructor(
|
||||
private actions$: Actions,
|
||||
private listService: ListService,
|
||||
private accountService: AccountService,
|
||||
private toastService: NbToastrService,
|
||||
) {
|
||||
}
|
||||
}
|
||||
93
projects/mastolists/src/app/shared/state/store/reducers.ts
Normal file
93
projects/mastolists/src/app/shared/state/store/reducers.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import {createReducer, on} from "@ngrx/store";
|
||||
import {Account, List} from "projects/mastodon-api/src/public-api";
|
||||
import {FiltersActions, ListActions, MastodonApiActions} from "./actions";
|
||||
|
||||
export interface Filters {
|
||||
username: string;
|
||||
freeText: ReadonlyArray<string>;
|
||||
lists: ReadonlyArray<string>;
|
||||
unlisted: boolean;
|
||||
}
|
||||
|
||||
export interface ApplicationState {
|
||||
listsLoading: boolean;
|
||||
listAccountsLoading: boolean;
|
||||
followingsLoading: boolean;
|
||||
lists: ReadonlyArray<List>;
|
||||
listsAccounts: { [listId: string]: string[] };
|
||||
followings: ReadonlyArray<Account>;
|
||||
filters: Filters;
|
||||
}
|
||||
|
||||
export const initialState: ApplicationState = {
|
||||
listsLoading: false,
|
||||
listAccountsLoading: false,
|
||||
followingsLoading: false,
|
||||
lists: [],
|
||||
listsAccounts: {},
|
||||
followings: [],
|
||||
filters: {
|
||||
username: '',
|
||||
freeText: [],
|
||||
lists: [],
|
||||
unlisted: false,
|
||||
}
|
||||
}
|
||||
|
||||
export const applicationStateReducers = createReducer(
|
||||
initialState,
|
||||
on(MastodonApiActions.loadLists, _state => {
|
||||
return {..._state, listsLoading: true};
|
||||
}),
|
||||
on(MastodonApiActions.listsLoaded, (_state, {lists}) => {
|
||||
return {..._state, listsLoading: false, listAccountsLoading: true, lists: lists,};
|
||||
}),
|
||||
on(MastodonApiActions.accountIdsForListsLoaded, (_state, {mappings}) => {
|
||||
return {..._state, listAccountsLoading: false, listsAccounts: mappings};
|
||||
}),
|
||||
on(MastodonApiActions.followingsLoaded, (_state, {followings}) => {
|
||||
return {..._state, followingsLoading: false, followings};
|
||||
}),
|
||||
on(MastodonApiActions.followingsLoadedError, (_state, {error}) => {
|
||||
console.error(error);
|
||||
return {..._state};
|
||||
}),
|
||||
on(ListActions.addAccountToList, (_state, {accountId, listId}) => {
|
||||
const existingAccountIds = _state.listsAccounts[listId] || [];
|
||||
const newAccountIds = [...existingAccountIds, accountId];
|
||||
const newMap: { [listId: string]: string[] } = {..._state.listsAccounts, [listId]: newAccountIds};
|
||||
return <ApplicationState>{
|
||||
..._state,
|
||||
listsAccounts: newMap,
|
||||
}
|
||||
}),
|
||||
on(ListActions.removeAccountFromList, (_state, {accountId, listId}) => {
|
||||
const existingAccountIds = _state.listsAccounts[listId] || [];
|
||||
const newAccountIds = existingAccountIds.filter(id => id !== accountId);
|
||||
const newMap: { [listId: string]: string[] } = {..._state.listsAccounts, [listId]: newAccountIds};
|
||||
return <ApplicationState>{
|
||||
..._state,
|
||||
listsAccounts: newMap,
|
||||
}
|
||||
}),
|
||||
on(FiltersActions.setUsername, (_state, {username}) => {
|
||||
return {..._state, filters: {..._state.filters, username}};
|
||||
}),
|
||||
on(FiltersActions.setFreeText, (_state, {freeText}) => {
|
||||
return {..._state, filters: {..._state.filters, freeText}};
|
||||
}),
|
||||
on(FiltersActions.setLists, (_state, {lists}) => {
|
||||
return {..._state, filters: {..._state.filters, lists}};
|
||||
}),
|
||||
on(FiltersActions.setUnlisted, (_state, {unlisted}) => {
|
||||
let newState = {..._state, filters: {..._state.filters, unlisted}};
|
||||
if (unlisted) {
|
||||
newState = {...newState, filters: {...newState.filters, lists: []}};
|
||||
}
|
||||
return newState;
|
||||
}),
|
||||
on(FiltersActions.clearFilters, _state => {
|
||||
return {..._state, filters: initialState.filters};
|
||||
}),
|
||||
)
|
||||
;
|
||||
114
projects/mastolists/src/app/shared/state/store/selectors.ts
Normal file
114
projects/mastolists/src/app/shared/state/store/selectors.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import {ApplicationState} from "./reducers";
|
||||
import {createFeatureSelector, createSelector, State} from "@ngrx/store";
|
||||
|
||||
|
||||
export const applicationStateFeature = createFeatureSelector<ApplicationState>('applicationState');
|
||||
|
||||
export const selectLists = createSelector(
|
||||
applicationStateFeature,
|
||||
(state: ApplicationState) => state.lists
|
||||
)
|
||||
export const selectFollowings = createSelector(
|
||||
applicationStateFeature,
|
||||
(state: ApplicationState) => state.followings
|
||||
);
|
||||
|
||||
export const selectMappings = createSelector(
|
||||
applicationStateFeature,
|
||||
(state: ApplicationState) => state.listsAccounts
|
||||
);
|
||||
|
||||
export const selectListsWithAccounts = createSelector(
|
||||
selectLists,
|
||||
selectFollowings,
|
||||
selectMappings,
|
||||
(lists, followings, mappings) => {
|
||||
return lists.map(list => {
|
||||
const accountIds = mappings[list.id];
|
||||
const accounts = followings.filter(account => accountIds.includes(account.id));
|
||||
return {...list, accounts};
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export const selectFollowingsWithLists = createSelector(
|
||||
selectFollowings,
|
||||
selectLists,
|
||||
selectMappings,
|
||||
(followings, lists, mappings) => {
|
||||
return followings.map(account => {
|
||||
const listIds = Object.keys(mappings).filter(listId => mappings[listId].includes(account.id));
|
||||
const accountLists = lists.filter(list => listIds.includes(list.id));
|
||||
return {...account, lists: accountLists};
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export const selectFilters = createSelector(
|
||||
applicationStateFeature,
|
||||
(state: ApplicationState) => state.filters
|
||||
);
|
||||
|
||||
export const selectFiltersForForm = createSelector(
|
||||
applicationStateFeature,
|
||||
(state: ApplicationState) => {
|
||||
const filters = state.filters;
|
||||
const parts = [];
|
||||
if (filters.username) {
|
||||
parts.push(`@${filters.username}`);
|
||||
}
|
||||
parts.push(...filters.freeText);
|
||||
const fullText = parts.join(',');
|
||||
return {
|
||||
fullText: fullText.length > 0 ? fullText : undefined,
|
||||
lists: filters.lists,
|
||||
unlisted: filters.unlisted,
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const selectFilteredFollowingsWithLists = createSelector(
|
||||
selectFollowingsWithLists,
|
||||
selectFilters,
|
||||
(followings, filters) => {
|
||||
return followings.filter(account => {
|
||||
const usernameMatch = filters.username.length === 0 || account.username.toLowerCase().startsWith(filters.username.toLowerCase());
|
||||
const freeTextMatch = filters.freeText.length === 0 || filters.freeText
|
||||
.every(text => {
|
||||
const lowerCaseText = text.toLowerCase();
|
||||
return account.username.toLowerCase().includes(lowerCaseText)
|
||||
|| account.note.toLowerCase().includes(lowerCaseText)
|
||||
|| account.fields.some(field => field.name.toLowerCase().includes(lowerCaseText))
|
||||
|| account.fields.some(field => field.value.toLowerCase().includes(lowerCaseText));
|
||||
});
|
||||
const listsMatch = filters.lists.length === 0 || filters.lists.some(listId => account.lists.some(list => list.id === listId));
|
||||
const unlistedMatch = filters.unlisted ? account.lists.length === 0 : true;
|
||||
return usernameMatch && freeTextMatch && listsMatch && unlistedMatch;
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export const selectLoading = createSelector(
|
||||
applicationStateFeature,
|
||||
(state: ApplicationState) => state.listsLoading || state.listAccountsLoading || state.followingsLoading
|
||||
);
|
||||
|
||||
export const selectLoadingPercentage = createSelector(
|
||||
applicationStateFeature,
|
||||
(state: ApplicationState) => {
|
||||
if (!state.listsLoading && !state.listAccountsLoading && !state.followingsLoading) {
|
||||
return 100;
|
||||
}
|
||||
if (state.listsLoading && !state.listAccountsLoading && !state.followingsLoading) {
|
||||
return 50;
|
||||
}
|
||||
if (!state.listsLoading && state.listAccountsLoading && !state.followingsLoading) {
|
||||
return 80;
|
||||
}
|
||||
if (!state.listsLoading && !state.listAccountsLoading && state.followingsLoading) {
|
||||
return 50;
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
);
|
||||
|
||||
17
projects/mastolists/src/app/sync/sync-routing.module.ts
Normal file
17
projects/mastolists/src/app/sync/sync-routing.module.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import {RouterModule, Routes} from "@angular/router";
|
||||
import {NgModule} from "@angular/core";
|
||||
import {SyncComponent} from "./sync/sync.component";
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: SyncComponent
|
||||
}
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class SyncRoutingModule {
|
||||
}
|
||||
23
projects/mastolists/src/app/sync/sync.module.ts
Normal file
23
projects/mastolists/src/app/sync/sync.module.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import {NgModule} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {SyncComponent} from './sync/sync.component';
|
||||
import {SharedModule} from "../shared/shared.module";
|
||||
import {SyncRoutingModule} from "./sync-routing.module";
|
||||
import {NbCardModule, NbSpinnerModule} from "@nebular/theme";
|
||||
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
SyncComponent
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
SharedModule,
|
||||
SyncRoutingModule,
|
||||
// Nebula
|
||||
NbCardModule,
|
||||
NbSpinnerModule,
|
||||
]
|
||||
})
|
||||
export class SyncModule {
|
||||
}
|
||||
50
projects/mastolists/src/app/sync/sync/sync.component.html
Normal file
50
projects/mastolists/src/app/sync/sync/sync.component.html
Normal file
@@ -0,0 +1,50 @@
|
||||
<nb-card>
|
||||
<nb-card-header>
|
||||
<h1>Sync Remote Data with your Local Storage</h1>
|
||||
</nb-card-header>
|
||||
<nb-card-body>
|
||||
<nb-card>
|
||||
<nb-card-header>
|
||||
Currently in Local Store
|
||||
</nb-card-header>
|
||||
<nb-card-body>
|
||||
<table style="width: 100%;">
|
||||
<tr>
|
||||
<th colspan="2">Followings</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Number of followings</td>
|
||||
<td>{{(followings$ | async)?.length}}</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div class="divider"></div>
|
||||
<table style="width: 100%;">
|
||||
<tr>
|
||||
<th colspan="2">Number of Accounts in List</th>
|
||||
</tr>
|
||||
<tr *ngFor="let list of lists$ | async">
|
||||
<td>{{list?.title}}</td>
|
||||
<td>{{list?.accounts?.length}}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</nb-card-body>
|
||||
<nb-card-footer>
|
||||
<div>
|
||||
<button nbButton type="button"
|
||||
[nbSpinner]="(loading$ | async)!"
|
||||
[disabled]="(loading$ | async)!"
|
||||
(click)="loadListsAndAccounts()">Get Lists and Accounts
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div *ngIf="(lists$ | async) && (followings$ | async)">
|
||||
<p>Now you can:</p>
|
||||
<ul>
|
||||
<li>Select the <a [routerLink]="['/followings']">List view</a> to add and remove users from lists</li>
|
||||
<li>... or use the experimental <a [routerLink]="['/matrix']">Matrix view</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</nb-card-footer>
|
||||
</nb-card>
|
||||
</nb-card-body>
|
||||
</nb-card>
|
||||
23
projects/mastolists/src/app/sync/sync/sync.component.spec.ts
Normal file
23
projects/mastolists/src/app/sync/sync/sync.component.spec.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { SyncComponent } from './sync.component';
|
||||
|
||||
describe('SyncComponent', () => {
|
||||
let component: SyncComponent;
|
||||
let fixture: ComponentFixture<SyncComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [ SyncComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(SyncComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
29
projects/mastolists/src/app/sync/sync/sync.component.ts
Normal file
29
projects/mastolists/src/app/sync/sync/sync.component.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import {Component} from '@angular/core';
|
||||
import {select, Store} from "@ngrx/store";
|
||||
import {MastodonApiActions} from "../../shared/state/store/actions";
|
||||
import {selectFollowings, selectLists, selectListsWithAccounts, selectLoading} from "../../shared/state/store/selectors";
|
||||
import {Observable, tap} from "rxjs";
|
||||
import {Account, List} from 'projects/mastodon-api/src/public-api';
|
||||
|
||||
@Component({
|
||||
selector: 'app-sync',
|
||||
templateUrl: './sync.component.html',
|
||||
styleUrls: ['./sync.component.scss']
|
||||
})
|
||||
export class SyncComponent {
|
||||
loading$: Observable<boolean>;
|
||||
followings$: Observable<ReadonlyArray<Account>>;
|
||||
lists$: Observable<ReadonlyArray<List>>;
|
||||
|
||||
constructor(private store: Store) {
|
||||
this.followings$ = this.store.pipe(select(selectFollowings))
|
||||
this.lists$ = this.store.pipe(select(selectListsWithAccounts));
|
||||
this.loading$ = this.store.pipe(select(selectLoading));
|
||||
}
|
||||
|
||||
loadListsAndAccounts() {
|
||||
this.store.dispatch(MastodonApiActions.loadLists());
|
||||
this.store.dispatch(MastodonApiActions.loadFollowings());
|
||||
}
|
||||
|
||||
}
|
||||
0
projects/mastolists/src/assets/.gitkeep
Normal file
0
projects/mastolists/src/assets/.gitkeep
Normal file
BIN
projects/mastolists/src/assets/fonts/inter/woff/Inter-Black.woff
Normal file
BIN
projects/mastolists/src/assets/fonts/inter/woff/Inter-Black.woff
Normal file
Binary file not shown.
Binary file not shown.
BIN
projects/mastolists/src/assets/fonts/inter/woff/Inter-Bold.woff
Normal file
BIN
projects/mastolists/src/assets/fonts/inter/woff/Inter-Bold.woff
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user