initial commit
This commit is contained in:
+104
@@ -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]*-*
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Generated
+21194
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
|
||||||
|
"dest": "../../dist/mastodon-api",
|
||||||
|
"lib": {
|
||||||
|
"entryFile": "src/public-api.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './public/public';
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/*
|
||||||
|
* Public API Surface of mastodon-api
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './lib/services/services';
|
||||||
|
export * from './lib/interfaces/interfaces';
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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!');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
}
|
||||||
@@ -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}));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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};
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
;
|
||||||
@@ -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;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
@@ -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 {
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
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