ES Modulesに移行
This commit is contained in:
parent
0c3df4245d
commit
69212dd99a
105 changed files with 3154 additions and 3230 deletions
|
@ -16,13 +16,7 @@ module.exports = {
|
||||||
'@typescript-eslint'
|
'@typescript-eslint'
|
||||||
],
|
],
|
||||||
'rules': {
|
'rules': {
|
||||||
'indent': [
|
'indent': ['error', 2, { 'SwitchCase': 1 } ],
|
||||||
'error',
|
|
||||||
'tab',
|
|
||||||
{
|
|
||||||
'SwitchCase': 1,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
'quotes': [
|
'quotes': [
|
||||||
'error',
|
'error',
|
||||||
'single'
|
'single'
|
||||||
|
@ -31,6 +25,7 @@ module.exports = {
|
||||||
'error',
|
'error',
|
||||||
'always'
|
'always'
|
||||||
],
|
],
|
||||||
'@typescript-eslint/explicit-module-boundary-types': 'off'
|
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||||
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
}
|
}
|
||||||
};
|
};
|
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -1,4 +1,5 @@
|
||||||
node_modules
|
node_modules
|
||||||
built
|
built
|
||||||
yarn-error.log
|
yarn-error.log
|
||||||
config.json
|
config.json
|
||||||
|
.yarn
|
9
.idea/codeStyles/Project.xml
generated
9
.idea/codeStyles/Project.xml
generated
|
@ -1,14 +1,19 @@
|
||||||
<component name="ProjectCodeStyleConfiguration">
|
<component name="ProjectCodeStyleConfiguration">
|
||||||
<code_scheme name="Project" version="173">
|
<code_scheme name="Project" version="173">
|
||||||
|
<option name="AUTODETECT_INDENTS" value="false" />
|
||||||
<JSCodeStyleSettings version="0">
|
<JSCodeStyleSettings version="0">
|
||||||
<option name="FORCE_SEMICOLON_STYLE" value="true" />
|
<option name="FORCE_SEMICOLON_STYLE" value="true" />
|
||||||
<option name="USE_DOUBLE_QUOTES" value="false" />
|
<option name="USE_DOUBLE_QUOTES" value="false" />
|
||||||
<option name="FORCE_QUOTE_STYlE" value="true" />
|
<option name="FORCE_QUOTE_STYlE" value="true" />
|
||||||
|
<option name="USE_EXPLICIT_JS_EXTENSION" value="TRUE" />
|
||||||
|
<option name="IMPORT_USE_NODE_RESOLUTION" value="FALSE" />
|
||||||
</JSCodeStyleSettings>
|
</JSCodeStyleSettings>
|
||||||
<TypeScriptCodeStyleSettings version="0">
|
<TypeScriptCodeStyleSettings version="0">
|
||||||
<option name="FORCE_SEMICOLON_STYLE" value="true" />
|
<option name="FORCE_SEMICOLON_STYLE" value="true" />
|
||||||
<option name="USE_DOUBLE_QUOTES" value="false" />
|
<option name="USE_DOUBLE_QUOTES" value="false" />
|
||||||
<option name="FORCE_QUOTE_STYlE" value="true" />
|
<option name="FORCE_QUOTE_STYlE" value="true" />
|
||||||
|
<option name="USE_EXPLICIT_JS_EXTENSION" value="TRUE" />
|
||||||
|
<option name="IMPORT_USE_NODE_RESOLUTION" value="GLOBAL" />
|
||||||
</TypeScriptCodeStyleSettings>
|
</TypeScriptCodeStyleSettings>
|
||||||
<codeStyleSettings language="JavaScript">
|
<codeStyleSettings language="JavaScript">
|
||||||
<option name="ALIGN_MULTILINE_PARAMETERS" value="false" />
|
<option name="ALIGN_MULTILINE_PARAMETERS" value="false" />
|
||||||
|
@ -21,7 +26,9 @@
|
||||||
<option name="ALIGN_MULTILINE_PARAMETERS" value="false" />
|
<option name="ALIGN_MULTILINE_PARAMETERS" value="false" />
|
||||||
<option name="ALIGN_MULTILINE_FOR" value="false" />
|
<option name="ALIGN_MULTILINE_FOR" value="false" />
|
||||||
<indentOptions>
|
<indentOptions>
|
||||||
<option name="USE_TAB_CHARACTER" value="true" />
|
<option name="INDENT_SIZE" value="2" />
|
||||||
|
<option name="CONTINUATION_INDENT_SIZE" value="2" />
|
||||||
|
<option name="TAB_SIZE" value="2" />
|
||||||
</indentOptions>
|
</indentOptions>
|
||||||
</codeStyleSettings>
|
</codeStyleSettings>
|
||||||
</code_scheme>
|
</code_scheme>
|
||||||
|
|
6
.idea/jsLibraryMappings.xml
generated
Normal file
6
.idea/jsLibraryMappings.xml
generated
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="JavaScriptLibraryMappings">
|
||||||
|
<includedPredefinedLibrary name="Node.js Core" />
|
||||||
|
</component>
|
||||||
|
</project>
|
|
@ -1,2 +1,2 @@
|
||||||
yarnPath: .yarn/releases/yarn-1.22.19.cjs
|
yarnPath: .yarn/releases/yarn-1.22.19.cjs
|
||||||
nodeLinker: node-modulesl
|
nodeLinker: node-modules
|
|
@ -1,8 +1,8 @@
|
||||||
const { readFileSync, writeFileSync } = require('fs');
|
import {readFileSync, writeFileSync} from 'fs';
|
||||||
|
|
||||||
const { version } = JSON.parse(readFileSync('./package.json', {
|
const { version } = JSON.parse(readFileSync('./package.json', {
|
||||||
encoding: 'UTF-8',
|
encoding: 'UTF-8',
|
||||||
flag: 'r',
|
flag: 'r',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
writeFileSync('built/meta.json', JSON.stringify({ version }));
|
writeFileSync('built/meta.json', JSON.stringify({ version }));
|
||||||
|
|
|
@ -1,16 +1,16 @@
|
||||||
import {MigrationInterface, QueryRunner} from 'typeorm';
|
import {MigrationInterface, QueryRunner} from 'typeorm';
|
||||||
|
|
||||||
export class Init1596513280623 implements MigrationInterface {
|
export class Init1596513280623 implements MigrationInterface {
|
||||||
name = 'Init1596513280623'
|
name = 'Init1596513280623';
|
||||||
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
await queryRunner.query('CREATE TABLE "user" ("id" SERIAL NOT NULL, "username" character varying NOT NULL, "host" character varying NOT NULL, "token" character varying NOT NULL, "prevNotesCount" integer NOT NULL, "prevFollowingCount" integer NOT NULL, "prevFollowersCount" integer NOT NULL, CONSTRAINT "PK_cace4a159ff9f2512dd42373760" PRIMARY KEY ("id"))');
|
await queryRunner.query('CREATE TABLE "user" ("id" SERIAL NOT NULL, "username" character varying NOT NULL, "host" character varying NOT NULL, "token" character varying NOT NULL, "prevNotesCount" integer NOT NULL, "prevFollowingCount" integer NOT NULL, "prevFollowersCount" integer NOT NULL, CONSTRAINT "PK_cace4a159ff9f2512dd42373760" PRIMARY KEY ("id"))');
|
||||||
await queryRunner.query('CREATE UNIQUE INDEX "IDX_6269eebacdb25de8569298a52a" ON "user" ("username", "host") ');
|
await queryRunner.query('CREATE UNIQUE INDEX "IDX_6269eebacdb25de8569298a52a" ON "user" ("username", "host") ');
|
||||||
}
|
}
|
||||||
|
|
||||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
await queryRunner.query('DROP INDEX "IDX_6269eebacdb25de8569298a52a"');
|
await queryRunner.query('DROP INDEX "IDX_6269eebacdb25de8569298a52a"');
|
||||||
await queryRunner.query('DROP TABLE "user"');
|
await queryRunner.query('DROP TABLE "user"');
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,18 +1,18 @@
|
||||||
import {MigrationInterface, QueryRunner} from 'typeorm';
|
import {MigrationInterface, QueryRunner} from 'typeorm';
|
||||||
|
|
||||||
export class Init21596514165166 implements MigrationInterface {
|
export class Init21596514165166 implements MigrationInterface {
|
||||||
name = 'Init21596514165166'
|
name = 'Init21596514165166';
|
||||||
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
await queryRunner.query('ALTER TABLE "user" ALTER COLUMN "prevNotesCount" SET DEFAULT 0');
|
await queryRunner.query('ALTER TABLE "user" ALTER COLUMN "prevNotesCount" SET DEFAULT 0');
|
||||||
await queryRunner.query('ALTER TABLE "user" ALTER COLUMN "prevFollowingCount" SET DEFAULT 0');
|
await queryRunner.query('ALTER TABLE "user" ALTER COLUMN "prevFollowingCount" SET DEFAULT 0');
|
||||||
await queryRunner.query('ALTER TABLE "user" ALTER COLUMN "prevFollowersCount" SET DEFAULT 0');
|
await queryRunner.query('ALTER TABLE "user" ALTER COLUMN "prevFollowersCount" SET DEFAULT 0');
|
||||||
}
|
}
|
||||||
|
|
||||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
await queryRunner.query('ALTER TABLE "user" ALTER COLUMN "prevFollowersCount" DROP DEFAULT');
|
await queryRunner.query('ALTER TABLE "user" ALTER COLUMN "prevFollowersCount" DROP DEFAULT');
|
||||||
await queryRunner.query('ALTER TABLE "user" ALTER COLUMN "prevFollowingCount" DROP DEFAULT');
|
await queryRunner.query('ALTER TABLE "user" ALTER COLUMN "prevFollowingCount" DROP DEFAULT');
|
||||||
await queryRunner.query('ALTER TABLE "user" ALTER COLUMN "prevNotesCount" DROP DEFAULT');
|
await queryRunner.query('ALTER TABLE "user" ALTER COLUMN "prevNotesCount" DROP DEFAULT');
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,18 +1,18 @@
|
||||||
import {MigrationInterface, QueryRunner} from 'typeorm';
|
import {MigrationInterface, QueryRunner} from 'typeorm';
|
||||||
|
|
||||||
export class mypage1599570288522 implements MigrationInterface {
|
export class mypage1599570288522 implements MigrationInterface {
|
||||||
name = 'mypage1599570288522'
|
name = 'mypage1599570288522';
|
||||||
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
await queryRunner.query('CREATE TABLE "used_token" ("token" character varying NOT NULL, CONSTRAINT "PK_7f2db4c33c33cd6b38e63393fe5" PRIMARY KEY ("token"))');
|
await queryRunner.query('CREATE TABLE "used_token" ("token" character varying NOT NULL, CONSTRAINT "PK_7f2db4c33c33cd6b38e63393fe5" PRIMARY KEY ("token"))');
|
||||||
await queryRunner.query('CREATE UNIQUE INDEX "IDX_7f2db4c33c33cd6b38e63393fe" ON "used_token" ("token") ');
|
await queryRunner.query('CREATE UNIQUE INDEX "IDX_7f2db4c33c33cd6b38e63393fe" ON "used_token" ("token") ');
|
||||||
await queryRunner.query('ALTER TABLE "user" ADD "misshaiToken" character varying NOT NULL DEFAULT \'\'');
|
await queryRunner.query('ALTER TABLE "user" ADD "misshaiToken" character varying NOT NULL DEFAULT \'\'');
|
||||||
}
|
}
|
||||||
|
|
||||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
await queryRunner.query('ALTER TABLE "user" DROP COLUMN "misshaiToken"');
|
await queryRunner.query('ALTER TABLE "user" DROP COLUMN "misshaiToken"');
|
||||||
await queryRunner.query('DROP INDEX "IDX_7f2db4c33c33cd6b38e63393fe"');
|
await queryRunner.query('DROP INDEX "IDX_7f2db4c33c33cd6b38e63393fe"');
|
||||||
await queryRunner.query('DROP TABLE "used_token"');
|
await queryRunner.query('DROP TABLE "used_token"');
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,16 +1,16 @@
|
||||||
import {MigrationInterface, QueryRunner} from 'typeorm';
|
import {MigrationInterface, QueryRunner} from 'typeorm';
|
||||||
|
|
||||||
export class mode1599577510614 implements MigrationInterface {
|
export class mode1599577510614 implements MigrationInterface {
|
||||||
name = 'mode1599577510614'
|
name = 'mode1599577510614';
|
||||||
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
await queryRunner.query('CREATE TYPE "user_alertmode_enum" AS ENUM(\'note\', \'notification\', \'nothing\')');
|
await queryRunner.query('CREATE TYPE "user_alertmode_enum" AS ENUM(\'note\', \'notification\', \'nothing\')');
|
||||||
await queryRunner.query('ALTER TABLE "user" ADD "alertMode" "user_alertmode_enum" NOT NULL DEFAULT \'note\'');
|
await queryRunner.query('ALTER TABLE "user" ADD "alertMode" "user_alertmode_enum" NOT NULL DEFAULT \'note\'');
|
||||||
}
|
}
|
||||||
|
|
||||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
await queryRunner.query('ALTER TABLE "user" DROP COLUMN "alertMode"');
|
await queryRunner.query('ALTER TABLE "user" DROP COLUMN "alertMode"');
|
||||||
await queryRunner.query('DROP TYPE "user_alertmode_enum"');
|
await queryRunner.query('DROP TYPE "user_alertmode_enum"');
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,22 +1,22 @@
|
||||||
import {MigrationInterface, QueryRunner} from 'typeorm';
|
import {MigrationInterface, QueryRunner} from 'typeorm';
|
||||||
|
|
||||||
export class visibility1609938844427 implements MigrationInterface {
|
export class visibility1609938844427 implements MigrationInterface {
|
||||||
name = 'visibility1609938844427'
|
name = 'visibility1609938844427';
|
||||||
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
await queryRunner.query('CREATE TYPE "user_visibility_enum" AS ENUM(\'public\', \'home\', \'followers\', \'users\')');
|
await queryRunner.query('CREATE TYPE "user_visibility_enum" AS ENUM(\'public\', \'home\', \'followers\', \'users\')');
|
||||||
await queryRunner.query('ALTER TABLE "user" ADD "visibility" "user_visibility_enum" NOT NULL DEFAULT \'home\'');
|
await queryRunner.query('ALTER TABLE "user" ADD "visibility" "user_visibility_enum" NOT NULL DEFAULT \'home\'');
|
||||||
await queryRunner.query('ALTER TABLE "user" ADD "localOnly" boolean NOT NULL DEFAULT false');
|
await queryRunner.query('ALTER TABLE "user" ADD "localOnly" boolean NOT NULL DEFAULT false');
|
||||||
await queryRunner.query('ALTER TABLE "user" ADD "remoteFollowersOnly" boolean NOT NULL DEFAULT false');
|
await queryRunner.query('ALTER TABLE "user" ADD "remoteFollowersOnly" boolean NOT NULL DEFAULT false');
|
||||||
await queryRunner.query('ALTER TABLE "user" ALTER COLUMN "alertMode" SET DEFAULT \'notification\'');
|
await queryRunner.query('ALTER TABLE "user" ALTER COLUMN "alertMode" SET DEFAULT \'notification\'');
|
||||||
}
|
}
|
||||||
|
|
||||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
await queryRunner.query('ALTER TABLE "user" ALTER COLUMN "alertMode" SET DEFAULT \'note\'');
|
await queryRunner.query('ALTER TABLE "user" ALTER COLUMN "alertMode" SET DEFAULT \'note\'');
|
||||||
await queryRunner.query('ALTER TABLE "user" DROP COLUMN "remoteFollowersOnly"');
|
await queryRunner.query('ALTER TABLE "user" DROP COLUMN "remoteFollowersOnly"');
|
||||||
await queryRunner.query('ALTER TABLE "user" DROP COLUMN "localOnly"');
|
await queryRunner.query('ALTER TABLE "user" DROP COLUMN "localOnly"');
|
||||||
await queryRunner.query('ALTER TABLE "user" DROP COLUMN "visibility"');
|
await queryRunner.query('ALTER TABLE "user" DROP COLUMN "visibility"');
|
||||||
await queryRunner.query('DROP TYPE "user_visibility_enum"');
|
await queryRunner.query('DROP TYPE "user_visibility_enum"');
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,14 +1,14 @@
|
||||||
import {MigrationInterface, QueryRunner} from 'typeorm';
|
import {MigrationInterface, QueryRunner} from 'typeorm';
|
||||||
|
|
||||||
export class template1609941393782 implements MigrationInterface {
|
export class template1609941393782 implements MigrationInterface {
|
||||||
name = 'template1609941393782'
|
name = 'template1609941393782';
|
||||||
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
await queryRunner.query('ALTER TABLE "user" ADD "template" character varying(280)');
|
await queryRunner.query('ALTER TABLE "user" ADD "template" character varying(280)');
|
||||||
}
|
}
|
||||||
|
|
||||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
await queryRunner.query('ALTER TABLE "user" DROP COLUMN "template"');
|
await queryRunner.query('ALTER TABLE "user" DROP COLUMN "template"');
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,18 +1,18 @@
|
||||||
import {MigrationInterface, QueryRunner} from 'typeorm';
|
import {MigrationInterface, QueryRunner} from 'typeorm';
|
||||||
|
|
||||||
export class rating1609948116186 implements MigrationInterface {
|
export class rating1609948116186 implements MigrationInterface {
|
||||||
name = 'rating1609948116186'
|
name = 'rating1609948116186';
|
||||||
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
await queryRunner.query('ALTER TABLE "user" ADD "prevRating" real NOT NULL DEFAULT 0');
|
await queryRunner.query('ALTER TABLE "user" ADD "prevRating" real NOT NULL DEFAULT 0');
|
||||||
await queryRunner.query('ALTER TABLE "user" ADD "rating" real NOT NULL DEFAULT 0');
|
await queryRunner.query('ALTER TABLE "user" ADD "rating" real NOT NULL DEFAULT 0');
|
||||||
await queryRunner.query('ALTER TABLE "user" ADD "bannedFromRanking" boolean NOT NULL DEFAULT false');
|
await queryRunner.query('ALTER TABLE "user" ADD "bannedFromRanking" boolean NOT NULL DEFAULT false');
|
||||||
}
|
}
|
||||||
|
|
||||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
await queryRunner.query('ALTER TABLE "user" DROP COLUMN "bannedFromRanking"');
|
await queryRunner.query('ALTER TABLE "user" DROP COLUMN "bannedFromRanking"');
|
||||||
await queryRunner.query('ALTER TABLE "user" DROP COLUMN "rating"');
|
await queryRunner.query('ALTER TABLE "user" DROP COLUMN "rating"');
|
||||||
await queryRunner.query('ALTER TABLE "user" DROP COLUMN "prevRating"');
|
await queryRunner.query('ALTER TABLE "user" DROP COLUMN "prevRating"');
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,13 +1,13 @@
|
||||||
import {MigrationInterface, QueryRunner} from "typeorm";
|
import {MigrationInterface, QueryRunner} from 'typeorm';
|
||||||
|
|
||||||
export class useRanking1651804009671 implements MigrationInterface {
|
export class useRanking1651804009671 implements MigrationInterface {
|
||||||
name = 'useRanking1651804009671'
|
name = 'useRanking1651804009671';
|
||||||
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
await queryRunner.query(`ALTER TABLE "user" ADD "useRanking" boolean NOT NULL DEFAULT false`);
|
await queryRunner.query('ALTER TABLE "user" ADD "useRanking" boolean NOT NULL DEFAULT false');
|
||||||
}
|
}
|
||||||
|
|
||||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "useRanking"`);
|
await queryRunner.query('ALTER TABLE "user" DROP COLUMN "useRanking"');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
24
package.json
24
package.json
|
@ -5,6 +5,7 @@
|
||||||
"main": "built/app.js",
|
"main": "built/app.js",
|
||||||
"author": "Shrimpia Network",
|
"author": "Shrimpia Network",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "run-s build:backend build:frontend",
|
"build": "run-s build:backend build:frontend",
|
||||||
"build:frontend": "webpack",
|
"build:frontend": "webpack",
|
||||||
|
@ -30,13 +31,11 @@
|
||||||
"@koa/multer": "^3.0.2",
|
"@koa/multer": "^3.0.2",
|
||||||
"@reduxjs/toolkit": "^1.6.1",
|
"@reduxjs/toolkit": "^1.6.1",
|
||||||
"axios": "^0.21.2",
|
"axios": "^0.21.2",
|
||||||
"class-transformer": "^0.4.0",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.0",
|
"class-validator": "^0.14.0",
|
||||||
"css-loader": "^6.2.0",
|
"css-loader": "^6.2.0",
|
||||||
"dayjs": "^1.10.7",
|
"dayjs": "^1.10.7",
|
||||||
"deepmerge": "^4.2.2",
|
"deepmerge": "^4.2.2",
|
||||||
"delay": "^4.4.0",
|
|
||||||
"fibers": "^5.0.0",
|
|
||||||
"i18next": "^20.6.1",
|
"i18next": "^20.6.1",
|
||||||
"i18next-browser-languagedetector": "^6.1.2",
|
"i18next-browser-languagedetector": "^6.1.2",
|
||||||
"insert-text-at-cursor": "^0.3.0",
|
"insert-text-at-cursor": "^0.3.0",
|
||||||
|
@ -65,7 +64,7 @@
|
||||||
"react-twemoji": "^0.5.0",
|
"react-twemoji": "^0.5.0",
|
||||||
"reflect-metadata": "^0.1.13",
|
"reflect-metadata": "^0.1.13",
|
||||||
"rndstr": "^1.0.0",
|
"rndstr": "^1.0.0",
|
||||||
"routing-controllers": "^0.9.0",
|
"routing-controllers": "^0.10.1",
|
||||||
"sass": "^1.38.2",
|
"sass": "^1.38.2",
|
||||||
"sass-loader": "^12.1.0",
|
"sass-loader": "^12.1.0",
|
||||||
"striptags": "^3.2.0",
|
"striptags": "^3.2.0",
|
||||||
|
@ -75,7 +74,7 @@
|
||||||
"tsc-alias": "^1.3.9",
|
"tsc-alias": "^1.3.9",
|
||||||
"tsconfig-paths-webpack-plugin": "^3.5.1",
|
"tsconfig-paths-webpack-plugin": "^3.5.1",
|
||||||
"typeorm": "0.2.25",
|
"typeorm": "0.2.25",
|
||||||
"typescript": "^4.4.2",
|
"typescript": "^4.9.5",
|
||||||
"uuid": "^8.3.0",
|
"uuid": "^8.3.0",
|
||||||
"webpack": "^5.75.0",
|
"webpack": "^5.75.0",
|
||||||
"webpack-cli": "^4.8.0",
|
"webpack-cli": "^4.8.0",
|
||||||
|
@ -91,7 +90,7 @@
|
||||||
"@types/koa-views": "^2.0.4",
|
"@types/koa-views": "^2.0.4",
|
||||||
"@types/markdown-it": "^12.2.3",
|
"@types/markdown-it": "^12.2.3",
|
||||||
"@types/ms": "^0.7.31",
|
"@types/ms": "^0.7.31",
|
||||||
"@types/node": "^8.0.29",
|
"@types/node": "^18.14.1",
|
||||||
"@types/node-cron": "^2.0.3",
|
"@types/node-cron": "^2.0.3",
|
||||||
"@types/object.pick": "^1.3.1",
|
"@types/object.pick": "^1.3.1",
|
||||||
"@types/react": "^17.0.19",
|
"@types/react": "^17.0.19",
|
||||||
|
@ -100,17 +99,14 @@
|
||||||
"@types/react-twemoji": "^0.4.0",
|
"@types/react-twemoji": "^0.4.0",
|
||||||
"@types/styled-components": "^5.1.13",
|
"@types/styled-components": "^5.1.13",
|
||||||
"@types/uuid": "^8.0.0",
|
"@types/uuid": "^8.0.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^4.30.0",
|
"@typescript-eslint/eslint-plugin": "^5.53.0",
|
||||||
"@typescript-eslint/parser": "^4.30.0",
|
"@typescript-eslint/parser": "^5.53.0",
|
||||||
"copyfiles": "^2.3.0",
|
"copyfiles": "^2.3.0",
|
||||||
"eslint": "^7.32.0",
|
"eslint": "^8.34.0",
|
||||||
"eslint-config-prettier": "^6.11.0",
|
|
||||||
"eslint-plugin-prettier": "^3.1.4",
|
|
||||||
"nodemon": "^2.0.4",
|
"nodemon": "^2.0.4",
|
||||||
"npm-run-all": "^4.1.5",
|
"npm-run-all": "^4.1.5",
|
||||||
"prettier": "^2.0.5",
|
"rimraf": "^4.1.2",
|
||||||
"rimraf": "^3.0.2",
|
"ts-node": "10.9.1"
|
||||||
"ts-node": "3.3.0"
|
|
||||||
},
|
},
|
||||||
"packageManager": "yarn@1.22.19"
|
"packageManager": "yarn@1.22.19"
|
||||||
}
|
}
|
||||||
|
|
10
src/app.ts
10
src/app.ts
|
@ -2,15 +2,15 @@ import 'reflect-metadata';
|
||||||
|
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
import { initDb } from './backend/services/db';
|
import { initDb } from './backend/services/db.js';
|
||||||
import { ua } from './backend/services/misskey';
|
import { ua } from './backend/services/misskey.js';
|
||||||
|
|
||||||
axios.defaults.headers['User-Agent'] = ua;
|
axios.defaults.headers['User-Agent'] = ua;
|
||||||
axios.defaults.headers['Content-Type'] = 'application/json';
|
axios.defaults.headers['Content-Type'] = 'application/json';
|
||||||
axios.defaults.validateStatus = (stat) => stat < 500;
|
axios.defaults.validateStatus = (stat) => stat < 500;
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
await initDb();
|
await initDb();
|
||||||
(await import('./backend/services/worker')).default();
|
(await import('./backend/services/worker.js')).default();
|
||||||
(await import('./backend/server')).default();
|
(await import('./backend/server.js')).default();
|
||||||
})();
|
})();
|
||||||
|
|
|
@ -11,40 +11,40 @@ export const defaultTemplate = '昨日のMisskeyの活動は\n\nノート: {note
|
||||||
export const currentTokenVersion = 2;
|
export const currentTokenVersion = 2;
|
||||||
|
|
||||||
export const misskeyAppInfo = {
|
export const misskeyAppInfo = {
|
||||||
name: 'Misskey Tools',
|
name: 'Misskey Tools',
|
||||||
description: 'A Professional Toolkit Designed for Misskey.',
|
description: 'A Professional Toolkit Designed for Misskey.',
|
||||||
permission: [
|
permission: [
|
||||||
'read:account',
|
'read:account',
|
||||||
'write:account',
|
'write:account',
|
||||||
'read:blocks',
|
'read:blocks',
|
||||||
'write:blocks',
|
'write:blocks',
|
||||||
'read:drive',
|
'read:drive',
|
||||||
'write:drive',
|
'write:drive',
|
||||||
'read:favorites',
|
'read:favorites',
|
||||||
'write:favorites',
|
'write:favorites',
|
||||||
'read:following',
|
'read:following',
|
||||||
'write:following',
|
'write:following',
|
||||||
'read:messaging',
|
'read:messaging',
|
||||||
'write:messaging',
|
'write:messaging',
|
||||||
'read:mutes',
|
'read:mutes',
|
||||||
'write:mutes',
|
'write:mutes',
|
||||||
'write:notes',
|
'write:notes',
|
||||||
'read:notifications',
|
'read:notifications',
|
||||||
'write:notifications',
|
'write:notifications',
|
||||||
'read:reactions',
|
'read:reactions',
|
||||||
'write:reactions',
|
'write:reactions',
|
||||||
'write:votes',
|
'write:votes',
|
||||||
'read:pages',
|
'read:pages',
|
||||||
'write:pages',
|
'write:pages',
|
||||||
'write:page-likes',
|
'write:page-likes',
|
||||||
'read:page-likes',
|
'read:page-likes',
|
||||||
'read:user-groups',
|
'read:user-groups',
|
||||||
'write:user-groups',
|
'write:user-groups',
|
||||||
'read:channels',
|
'read:channels',
|
||||||
'write:channels',
|
'write:channels',
|
||||||
'read:gallery',
|
'read:gallery',
|
||||||
'write:gallery',
|
'write:gallery',
|
||||||
'read:gallery-likes',
|
'read:gallery-likes',
|
||||||
'write:gallery-likes',
|
'write:gallery-likes',
|
||||||
],
|
],
|
||||||
} as const;
|
} as const;
|
||||||
|
|
|
@ -4,38 +4,38 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { BadRequestError, CurrentUser, Get, JsonController, OnUndefined, Post } from 'routing-controllers';
|
import { BadRequestError, CurrentUser, Get, JsonController, OnUndefined, Post } from 'routing-controllers';
|
||||||
import { IUser } from '../../common/types/user';
|
import { IUser } from '../../common/types/user.js';
|
||||||
import { config } from '../../config';
|
import { config } from '../../config.js';
|
||||||
import { work } from '../services/worker';
|
import { work } from '../services/worker.js';
|
||||||
import * as Store from '../store';
|
import * as Store from '../store.js';
|
||||||
|
|
||||||
@JsonController('/admin')
|
@JsonController('/admin')
|
||||||
export class AdminController {
|
export class AdminController {
|
||||||
@Get() getAdmin() {
|
@Get() getAdmin() {
|
||||||
const { username, host } = config.admin;
|
const { username, host } = config.admin;
|
||||||
return {
|
return {
|
||||||
username, host,
|
username, host,
|
||||||
acct: `@${username}@${host}`,
|
acct: `@${username}@${host}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('/misshai/log') getMisshaiLog(@CurrentUser({ required: true }) user: IUser) {
|
@Get('/misshai/log') getMisshaiLog(@CurrentUser({ required: true }) user: IUser) {
|
||||||
if (!user.isAdmin) {
|
if (!user.isAdmin) {
|
||||||
throw new BadRequestError('Not an Admin');
|
throw new BadRequestError('Not an Admin');
|
||||||
}
|
}
|
||||||
|
|
||||||
return Store.getState().misshaiWorkerLog;
|
return Store.getState().misshaiWorkerLog;
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnUndefined(204)
|
@OnUndefined(204)
|
||||||
@Post('/misshai/start') startMisshai(@CurrentUser({ required: true }) user: IUser) {
|
@Post('/misshai/start') startMisshai(@CurrentUser({ required: true }) user: IUser) {
|
||||||
if (!user.isAdmin) {
|
if (!user.isAdmin) {
|
||||||
throw new BadRequestError('Not an Admin');
|
throw new BadRequestError('Not an Admin');
|
||||||
}
|
}
|
||||||
if (Store.getState().nowCalculating) {
|
if (Store.getState().nowCalculating) {
|
||||||
throw new BadRequestError('Already started');
|
throw new BadRequestError('Already started');
|
||||||
}
|
}
|
||||||
|
|
||||||
work();
|
work();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,101 +4,101 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { BadRequestError, Body, CurrentUser, Delete, Get, JsonController, NotFoundError, OnUndefined, Param, Post, Put } from 'routing-controllers';
|
import { BadRequestError, Body, CurrentUser, Delete, Get, JsonController, NotFoundError, OnUndefined, Param, Post, Put } from 'routing-controllers';
|
||||||
import { IUser } from '../../common/types/user';
|
import { IUser } from '../../common/types/user.js';
|
||||||
import { Announcements } from '../models';
|
import { Announcements } from '../models/index.js';
|
||||||
import { AnnounceCreate } from './body/announce-create';
|
import { AnnounceCreate } from './body/announce-create.js';
|
||||||
import { AnnounceUpdate } from './body/announce-update';
|
import { AnnounceUpdate } from './body/announce-update.js';
|
||||||
import { IdProp } from './body/id-prop';
|
import { IdProp } from './body/id-prop.js';
|
||||||
|
|
||||||
@JsonController('/announcements')
|
@JsonController('/announcements')
|
||||||
export class AdminController {
|
export class AnnouncementController {
|
||||||
@Get() get() {
|
@Get() get() {
|
||||||
const query = Announcements.createQueryBuilder('announcement')
|
const query = Announcements.createQueryBuilder('announcement')
|
||||||
.orderBy('"announcement"."createdAt"', 'DESC');
|
.orderBy('"announcement"."createdAt"', 'DESC');
|
||||||
|
|
||||||
return query.getMany();
|
return query.getMany();
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnUndefined(204)
|
@OnUndefined(204)
|
||||||
@Post() async post(@CurrentUser({ required: true }) user: IUser, @Body({required: true}) {title, body}: AnnounceCreate) {
|
@Post() async post(@CurrentUser({ required: true }) user: IUser, @Body({required: true}) {title, body}: AnnounceCreate) {
|
||||||
if (!user.isAdmin) {
|
if (!user.isAdmin) {
|
||||||
throw new BadRequestError('Not an Admin');
|
throw new BadRequestError('Not an Admin');
|
||||||
}
|
}
|
||||||
if (!title || !body) {
|
if (!title || !body) {
|
||||||
throw new BadRequestError();
|
throw new BadRequestError();
|
||||||
}
|
}
|
||||||
await Announcements.insert({
|
await Announcements.insert({
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
title,
|
title,
|
||||||
body,
|
body,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnUndefined(204)
|
@OnUndefined(204)
|
||||||
@Put() async update(@CurrentUser({ required: true }) user: IUser, @Body() {id, title, body}: AnnounceUpdate) {
|
@Put() async update(@CurrentUser({ required: true }) user: IUser, @Body() {id, title, body}: AnnounceUpdate) {
|
||||||
if (!user.isAdmin) {
|
if (!user.isAdmin) {
|
||||||
throw new BadRequestError('Not an Admin');
|
throw new BadRequestError('Not an Admin');
|
||||||
}
|
}
|
||||||
if (!id || !title || !body) {
|
if (!id || !title || !body) {
|
||||||
throw new BadRequestError();
|
throw new BadRequestError();
|
||||||
}
|
}
|
||||||
if (!(await Announcements.findOne(id))) {
|
if (!(await Announcements.findOne(id))) {
|
||||||
throw new NotFoundError();
|
throw new NotFoundError();
|
||||||
}
|
}
|
||||||
|
|
||||||
await Announcements.update(id, {
|
await Announcements.update(id, {
|
||||||
title,
|
title,
|
||||||
body,
|
body,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnUndefined(204)
|
@OnUndefined(204)
|
||||||
@Post('/like/:id') async like(@CurrentUser({ required: true }) user: IUser, @Param('id') id: string) {
|
@Post('/like/:id') async like(@CurrentUser({ required: true }) user: IUser, @Param('id') id: string) {
|
||||||
if (!user.isAdmin) {
|
if (!user.isAdmin) {
|
||||||
throw new BadRequestError('Not an Admin');
|
throw new BadRequestError('Not an Admin');
|
||||||
}
|
}
|
||||||
const idNumber = Number(id);
|
const idNumber = Number(id);
|
||||||
if (isNaN(idNumber)) {
|
if (isNaN(idNumber)) {
|
||||||
throw new NotFoundError();
|
throw new NotFoundError();
|
||||||
}
|
}
|
||||||
if (!id) {
|
if (!id) {
|
||||||
throw new BadRequestError();
|
throw new BadRequestError();
|
||||||
}
|
}
|
||||||
|
|
||||||
const announcement = await Announcements.findOne(Number(idNumber));
|
const announcement = await Announcements.findOne(Number(idNumber));
|
||||||
|
|
||||||
if (!announcement) {
|
if (!announcement) {
|
||||||
throw new NotFoundError();
|
throw new NotFoundError();
|
||||||
}
|
}
|
||||||
|
|
||||||
await Announcements.update(id, {
|
await Announcements.update(id, {
|
||||||
like: announcement.like + 1,
|
like: announcement.like + 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
return announcement.like + 1;
|
return announcement.like + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete() async delete(@CurrentUser({ required: true }) user: IUser, @Body() {id}: IdProp) {
|
@Delete() async delete(@CurrentUser({ required: true }) user: IUser, @Body() {id}: IdProp) {
|
||||||
if (!user.isAdmin) {
|
if (!user.isAdmin) {
|
||||||
throw new BadRequestError('Not an Admin');
|
throw new BadRequestError('Not an Admin');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
throw new BadRequestError();
|
throw new BadRequestError();
|
||||||
}
|
}
|
||||||
|
|
||||||
await Announcements.delete(id);
|
await Announcements.delete(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('/:id') async getDetail(@Param('id') id: string) {
|
@Get('/:id') async getDetail(@Param('id') id: string) {
|
||||||
const idNumber = Number(id);
|
const idNumber = Number(id);
|
||||||
if (isNaN(idNumber)) {
|
if (isNaN(idNumber)) {
|
||||||
throw new NotFoundError();
|
throw new NotFoundError();
|
||||||
}
|
}
|
||||||
const announcement = await Announcements.findOne(idNumber);
|
const announcement = await Announcements.findOne(idNumber);
|
||||||
if (!announcement) {
|
if (!announcement) {
|
||||||
throw new NotFoundError();
|
throw new NotFoundError();
|
||||||
}
|
}
|
||||||
return announcement;
|
return announcement;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
export class AnnounceCreate {
|
export class AnnounceCreate {
|
||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
export class AnnounceUpdate {
|
export class AnnounceUpdate {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,3 @@
|
||||||
export class IdProp {
|
export class IdProp {
|
||||||
id: number;
|
id: number;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,25 +1,25 @@
|
||||||
import { IsIn, IsOptional } from 'class-validator';
|
import { IsIn, IsOptional } from 'class-validator';
|
||||||
import { AlertMode, alertModes } from '../../../common/types/alert-mode';
|
import { AlertMode, alertModes } from '../../../common/types/alert-mode.js';
|
||||||
import { visibilities, Visibility } from '../../../common/types/visibility';
|
import { visibilities, Visibility } from '../../../common/types/visibility.js';
|
||||||
|
|
||||||
export class UserSetting {
|
export class UserSetting {
|
||||||
@IsIn(alertModes)
|
@IsIn(alertModes)
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
alertMode?: AlertMode;
|
alertMode?: AlertMode;
|
||||||
|
|
||||||
@IsIn(visibilities)
|
@IsIn(visibilities)
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
visibility?: Visibility;
|
visibility?: Visibility;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
localOnly?: boolean;
|
localOnly?: boolean;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
remoteFollowersOnly?: boolean;
|
remoteFollowersOnly?: boolean;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
template?: string;
|
template?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
useRanking?: boolean;
|
useRanking?: boolean;
|
||||||
}
|
}
|
||||||
|
|
13
src/backend/controllers/index.ts
Normal file
13
src/backend/controllers/index.ts
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
import {MetaController} from './meta.js';
|
||||||
|
import {AdminController} from './admin.js';
|
||||||
|
import {AnnouncementController} from './announcement.js';
|
||||||
|
import {RankingController} from './ranking.js';
|
||||||
|
import {SessionController} from './session.js';
|
||||||
|
|
||||||
|
export default [
|
||||||
|
MetaController,
|
||||||
|
AdminController,
|
||||||
|
AnnouncementController,
|
||||||
|
RankingController,
|
||||||
|
SessionController,
|
||||||
|
];
|
|
@ -3,19 +3,17 @@
|
||||||
* @author Xeltica
|
* @author Xeltica
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { readFile } from 'fs';
|
|
||||||
import { Get, JsonController } from 'routing-controllers';
|
import { Get, JsonController } from 'routing-controllers';
|
||||||
import { promisify } from 'util';
|
import { Meta } from '../../common/types/meta.js';
|
||||||
import { Meta } from '../../common/types/meta';
|
import { currentTokenVersion } from '../const.js';
|
||||||
import { currentTokenVersion } from '../const';
|
import { meta } from '../../config.js';
|
||||||
|
|
||||||
@JsonController('/meta')
|
@JsonController('/meta')
|
||||||
export class MetaController {
|
export class MetaController {
|
||||||
@Get() async get(): Promise<Meta> {
|
@Get() async get(): Promise<Meta> {
|
||||||
const {version} = JSON.parse(await promisify(readFile)(__dirname + '/../../meta.json', { encoding: 'utf-8'}));
|
return {
|
||||||
return {
|
version: meta.version,
|
||||||
version,
|
currentTokenVersion,
|
||||||
currentTokenVersion,
|
};
|
||||||
};
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,34 +4,34 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Get, JsonController, QueryParam } from 'routing-controllers';
|
import { Get, JsonController, QueryParam } from 'routing-controllers';
|
||||||
import { getRanking } from '../functions/ranking';
|
import { getRanking } from '../functions/ranking.js';
|
||||||
import { getUserCount } from '../functions/users';
|
import { getUserCount } from '../functions/users.js';
|
||||||
import { getState } from '../store';
|
import { getState } from '../store.js';
|
||||||
|
|
||||||
@JsonController('/ranking')
|
@JsonController('/ranking')
|
||||||
export class RankingController {
|
export class RankingController {
|
||||||
@Get()
|
@Get()
|
||||||
async get(@QueryParam('limit', { required: false }) limit?: string) {
|
async get(@QueryParam('limit', { required: false }) limit?: string) {
|
||||||
return this.getResponse(getState().nowCalculating, limit ? Number(limit) : undefined);
|
return this.getResponse(getState().nowCalculating, limit ? Number(limit) : undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DBに問い合わせてランキングを取得する
|
* DBに問い合わせてランキングを取得する
|
||||||
* @param isCalculating 現在算出中かどうか
|
* @param isCalculating 現在算出中かどうか
|
||||||
* @param limit 何件取得するか
|
* @param limit 何件取得するか
|
||||||
* @returns ランキング
|
* @returns ランキング
|
||||||
*/
|
*/
|
||||||
private async getResponse(isCalculating: boolean, limit?: number) {
|
private async getResponse(isCalculating: boolean, limit?: number) {
|
||||||
const ranking = isCalculating ? [] : (await getRanking(limit)).map((u) => ({
|
const ranking = isCalculating ? [] : (await getRanking(limit)).map((u) => ({
|
||||||
id: u.id,
|
id: u.id,
|
||||||
username: u.username,
|
username: u.username,
|
||||||
host: u.host,
|
host: u.host,
|
||||||
rating: u.rating,
|
rating: u.rating,
|
||||||
}));
|
}));
|
||||||
return {
|
return {
|
||||||
isCalculating,
|
isCalculating,
|
||||||
userCount: await getUserCount(),
|
userCount: await getUserCount(),
|
||||||
ranking,
|
ranking,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,45 +5,45 @@
|
||||||
|
|
||||||
import { Body, CurrentUser, Delete, Get, JsonController, OnUndefined, Post, Put } from 'routing-controllers';
|
import { Body, CurrentUser, Delete, Get, JsonController, OnUndefined, Post, Put } from 'routing-controllers';
|
||||||
import { DeepPartial } from 'typeorm';
|
import { DeepPartial } from 'typeorm';
|
||||||
import { getScores } from '../functions/get-scores';
|
import { getScores } from '../functions/get-scores.js';
|
||||||
import { deleteUser, updateUser } from '../functions/users';
|
import { deleteUser, updateUser } from '../functions/users.js';
|
||||||
import { User } from '../models/entities/user';
|
import { User } from '../models/entities/user.js';
|
||||||
import { sendAlert } from '../services/send-alert';
|
import { sendAlert } from '../services/send-alert.js';
|
||||||
import { UserSetting } from './body/user-setting';
|
import { UserSetting } from './body/user-setting.js';
|
||||||
|
|
||||||
@JsonController('/session')
|
@JsonController('/session')
|
||||||
export class SessionController {
|
export class SessionController {
|
||||||
@Get() get(@CurrentUser({ required: true }) user: User) {
|
@Get() get(@CurrentUser({ required: true }) user: User) {
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('/score')
|
@Get('/score')
|
||||||
async getScore(@CurrentUser({ required: true }) user: User) {
|
async getScore(@CurrentUser({ required: true }) user: User) {
|
||||||
return getScores(user);
|
return getScores(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnUndefined(204)
|
@OnUndefined(204)
|
||||||
@Put() async updateSetting(@CurrentUser({ required: true }) user: User, @Body() setting: UserSetting) {
|
@Put() async updateSetting(@CurrentUser({ required: true }) user: User, @Body() setting: UserSetting) {
|
||||||
const s: DeepPartial<User> = {};
|
const s: DeepPartial<User> = {};
|
||||||
if (setting.alertMode != null) s.alertMode = setting.alertMode;
|
if (setting.alertMode != null) s.alertMode = setting.alertMode;
|
||||||
if (setting.visibility != null) s.visibility = setting.visibility;
|
if (setting.visibility != null) s.visibility = setting.visibility;
|
||||||
if (setting.localOnly != null) s.localOnly = setting.localOnly;
|
if (setting.localOnly != null) s.localOnly = setting.localOnly;
|
||||||
if (setting.remoteFollowersOnly != null) s.remoteFollowersOnly = setting.remoteFollowersOnly;
|
if (setting.remoteFollowersOnly != null) s.remoteFollowersOnly = setting.remoteFollowersOnly;
|
||||||
if (setting.template !== undefined) s.template = setting.template;
|
if (setting.template !== undefined) s.template = setting.template;
|
||||||
if (setting.useRanking !== undefined) s.useRanking = setting.useRanking;
|
if (setting.useRanking !== undefined) s.useRanking = setting.useRanking;
|
||||||
if (Object.keys(s).length === 0) return;
|
if (Object.keys(s).length === 0) return;
|
||||||
await updateUser(user.username, user.host, s);
|
await updateUser(user.username, user.host, s);
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnUndefined(204)
|
@OnUndefined(204)
|
||||||
@Post('/alert') async testAlert(@CurrentUser({ required: true }) user: User) {
|
@Post('/alert') async testAlert(@CurrentUser({ required: true }) user: User) {
|
||||||
await sendAlert(user);
|
await sendAlert(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnUndefined(204)
|
@OnUndefined(204)
|
||||||
@Delete() async delete(@CurrentUser({ required: true }) user: User) {
|
@Delete() async delete(@CurrentUser({ required: true }) user: User) {
|
||||||
await deleteUser(user.username, user.host);
|
await deleteUser(user.username, user.host);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { Context } from 'koa';
|
import { Context } from 'koa';
|
||||||
import { ErrorCode } from '../common/types/error-code';
|
import { ErrorCode } from '../common/types/error-code.js';
|
||||||
|
|
||||||
export const die = (ctx: Context, error: ErrorCode = 'other', status = 400): Promise<void> => {
|
export const die = (ctx: Context, error: ErrorCode = 'other', status = 400): Promise<void> => {
|
||||||
ctx.status = status;
|
ctx.status = status;
|
||||||
return ctx.render('frontend', { error });
|
return ctx.render('frontend', { error });
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import {MisskeyError} from '../services/misskey';
|
import {MisskeyError} from '../services/misskey.js';
|
||||||
|
|
||||||
export const errorToString = (e: Error) => {
|
export const errorToString = (e: Error) => {
|
||||||
if (e instanceof MisskeyError) {
|
if (e instanceof MisskeyError) {
|
||||||
return JSON.stringify(e.error);
|
return JSON.stringify(e.error, null, ' ');
|
||||||
}
|
}
|
||||||
return `${e.name}: ${e.message}\n${e.stack}`;
|
return `${e.name}: ${e.message}\n${e.stack}`;
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,16 +1,16 @@
|
||||||
import rndstr from 'rndstr';
|
import rndstr from 'rndstr';
|
||||||
import { UsedToken } from '../models/entities/used-token';
|
import { UsedToken } from '../models/entities/used-token.js';
|
||||||
import { UsedTokens } from '../models';
|
import { UsedTokens } from '../models/index.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* トークンを生成します
|
* トークンを生成します
|
||||||
*/
|
*/
|
||||||
export const genToken = async (): Promise<string> => {
|
export const genToken = async (): Promise<string> => {
|
||||||
let used: UsedToken | undefined = undefined;
|
let used: UsedToken | undefined = undefined;
|
||||||
let token: string;
|
let token: string;
|
||||||
do {
|
do {
|
||||||
token = rndstr(32);
|
token = rndstr(32);
|
||||||
used = await UsedTokens.findOne({ token });
|
used = await UsedTokens.findOne({ token });
|
||||||
} while (used !== undefined);
|
} while (used !== undefined);
|
||||||
return token;
|
return token;
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import { User } from '../models/entities/user';
|
import { User } from '../models/entities/user.js';
|
||||||
import { toSignedString } from '../../common/functions/to-signed-string';
|
import { toSignedString } from '../../common/functions/to-signed-string.js';
|
||||||
import {Count} from '../models/count';
|
import {Count} from '../models/count.js';
|
||||||
import {api} from '../services/misskey';
|
import {api} from '../services/misskey.js';
|
||||||
import {Score} from '../../common/types/score';
|
import {Score} from '../../common/types/score.js';
|
||||||
import {MiUser} from './update-score';
|
import {MiUser} from './update-score.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ユーザーのスコアを取得します。
|
* ユーザーのスコアを取得します。
|
||||||
|
@ -11,15 +11,15 @@ import {MiUser} from './update-score';
|
||||||
* @returns ユーザーのスコア
|
* @returns ユーザーのスコア
|
||||||
*/
|
*/
|
||||||
export const getScores = async (user: User): Promise<Score> => {
|
export const getScores = async (user: User): Promise<Score> => {
|
||||||
// TODO 毎回取ってくるのも微妙なので、ある程度キャッシュしたいかも
|
// TODO 毎回取ってくるのも微妙なので、ある程度キャッシュしたいかも
|
||||||
const miUser = await api<MiUser>(user.host, 'users/show', { username: user.username }, user.token);
|
const miUser = await api<MiUser>(user.host, 'users/show', { username: user.username }, user.token);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
notesCount: miUser.notesCount,
|
notesCount: miUser.notesCount,
|
||||||
followingCount: miUser.followingCount,
|
followingCount: miUser.followingCount,
|
||||||
followersCount: miUser.followersCount,
|
followersCount: miUser.followersCount,
|
||||||
...getDelta(user, miUser),
|
...getDelta(user, miUser),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -29,9 +29,9 @@ export const getScores = async (user: User): Promise<Score> => {
|
||||||
* @returns ユーザーのスコア差分
|
* @returns ユーザーのスコア差分
|
||||||
*/
|
*/
|
||||||
export const getDelta = (user: User, count: Count) => {
|
export const getDelta = (user: User, count: Count) => {
|
||||||
return {
|
return {
|
||||||
notesDelta: toSignedString(count.notesCount - user.prevNotesCount),
|
notesDelta: toSignedString(count.notesCount - user.prevNotesCount),
|
||||||
followingDelta: toSignedString(count.followingCount - user.prevFollowingCount),
|
followingDelta: toSignedString(count.followingCount - user.prevFollowingCount),
|
||||||
followersDelta: toSignedString(count.followersCount - user.prevFollowersCount),
|
followersDelta: toSignedString(count.followersCount - user.prevFollowersCount),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { Users } from '../models';
|
import { Users } from '../models/index.js';
|
||||||
import { User } from '../models/entities/user';
|
import { User } from '../models/entities/user.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ミス廃ランキングを取得する
|
* ミス廃ランキングを取得する
|
||||||
|
@ -7,15 +7,15 @@ import { User } from '../models/entities/user';
|
||||||
* @returns ミス廃ランキング
|
* @returns ミス廃ランキング
|
||||||
*/
|
*/
|
||||||
export const getRanking = async (limit?: number | null): Promise<User[]> => {
|
export const getRanking = async (limit?: number | null): Promise<User[]> => {
|
||||||
const query = Users.createQueryBuilder('user')
|
const query = Users.createQueryBuilder('user')
|
||||||
.where('"user"."useRanking" IS TRUE')
|
.where('"user"."useRanking" IS TRUE')
|
||||||
.andWhere('"user"."bannedFromRanking" IS NOT TRUE')
|
.andWhere('"user"."bannedFromRanking" IS NOT TRUE')
|
||||||
.andWhere('"user"."rating" <> \'NaN\'')
|
.andWhere('"user"."rating" <> \'NaN\'')
|
||||||
.orderBy('"user".rating', 'DESC');
|
.orderBy('"user".rating', 'DESC');
|
||||||
|
|
||||||
if (limit) {
|
if (limit) {
|
||||||
query.limit(limit);
|
query.limit(limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
return await query.getMany();
|
return await query.getMany();
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
import { User } from '../models/entities/user';
|
import { User } from '../models/entities/user.js';
|
||||||
import { updateUser } from './users';
|
import { updateUser } from './users.js';
|
||||||
import { MiUser } from './update-score';
|
import { MiUser } from './update-score.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ユーザーのレーティングを更新します
|
* ユーザーのレーティングを更新します
|
||||||
|
@ -10,9 +10,9 @@ import { MiUser } from './update-score';
|
||||||
* @param miUser Misskeyのユーザー
|
* @param miUser Misskeyのユーザー
|
||||||
*/
|
*/
|
||||||
export const updateRating = async (user: User, miUser: MiUser): Promise<void> => {
|
export const updateRating = async (user: User, miUser: MiUser): Promise<void> => {
|
||||||
const elapsedDays = dayjs().diff(dayjs(miUser.createdAt), 'd') + 1;
|
const elapsedDays = dayjs().diff(dayjs(miUser.createdAt), 'd') + 1;
|
||||||
await updateUser(user.username, user.host, {
|
await updateUser(user.username, user.host, {
|
||||||
prevRating: user.rating,
|
prevRating: user.rating,
|
||||||
rating: miUser.notesCount / elapsedDays,
|
rating: miUser.notesCount / elapsedDays,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,15 +1,15 @@
|
||||||
import { User } from '../models/entities/user';
|
import { User } from '../models/entities/user.js';
|
||||||
import { updateUser } from './users';
|
import { updateUser } from './users.js';
|
||||||
import {Count} from '../models/count';
|
import {Count} from '../models/count.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Misskeyのユーザーモデル
|
* Misskeyのユーザーモデル
|
||||||
*/
|
*/
|
||||||
export type MiUser = {
|
export type MiUser = {
|
||||||
notesCount: number,
|
notesCount: number,
|
||||||
followingCount: number,
|
followingCount: number,
|
||||||
followersCount: number,
|
followersCount: number,
|
||||||
createdAt: string,
|
createdAt: string,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -18,9 +18,9 @@ export type MiUser = {
|
||||||
* @param count 統計
|
* @param count 統計
|
||||||
*/
|
*/
|
||||||
export const updateScore = async (user: User, count: Count): Promise<void> => {
|
export const updateScore = async (user: User, count: Count): Promise<void> => {
|
||||||
await updateUser(user.username, user.host, {
|
await updateUser(user.username, user.host, {
|
||||||
prevNotesCount: count.notesCount ?? 0,
|
prevNotesCount: count.notesCount ?? 0,
|
||||||
prevFollowingCount: count.followingCount ?? 0,
|
prevFollowingCount: count.followingCount ?? 0,
|
||||||
prevFollowersCount: count.followersCount ?? 0,
|
prevFollowersCount: count.followersCount ?? 0,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,22 +1,22 @@
|
||||||
import { User } from '../models/entities/user';
|
import { User } from '../models/entities/user.js';
|
||||||
import { Users } from '../models';
|
import { Users } from '../models/index.js';
|
||||||
import { DeepPartial } from 'typeorm';
|
import { DeepPartial } from 'typeorm';
|
||||||
import { genToken } from './gen-token';
|
import { genToken } from './gen-token.js';
|
||||||
import { IUser } from '../../common/types/user';
|
import { IUser } from '../../common/types/user.js';
|
||||||
import { config } from '../../config';
|
import { config } from '../../config.js';
|
||||||
import { currentTokenVersion } from '../const';
|
import { currentTokenVersion } from '../const.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* IUser インターフェイスに変換します。
|
* IUser インターフェイスに変換します。
|
||||||
*/
|
*/
|
||||||
const packUser = (user: User | undefined): IUser | undefined => {
|
const packUser = (user: User | undefined): IUser | undefined => {
|
||||||
if (!user) return undefined;
|
if (!user) return undefined;
|
||||||
const { username: adminName, host: adminHost } = config.admin;
|
const { username: adminName, host: adminHost } = config.admin;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...user,
|
...user,
|
||||||
isAdmin: adminName === user.username && adminHost === user.host,
|
isAdmin: adminName === user.username && adminHost === user.host,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -26,7 +26,7 @@ const packUser = (user: User | undefined): IUser | undefined => {
|
||||||
* @returns ユーザー
|
* @returns ユーザー
|
||||||
*/
|
*/
|
||||||
export const getUser = (username: string, host: string): Promise<IUser | undefined> => {
|
export const getUser = (username: string, host: string): Promise<IUser | undefined> => {
|
||||||
return Users.findOne({ username, host }).then(packUser);
|
return Users.findOne({ username, host }).then(packUser);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -35,13 +35,13 @@ export const getUser = (username: string, host: string): Promise<IUser | undefin
|
||||||
* @returns ミス廃トークン
|
* @returns ミス廃トークン
|
||||||
*/
|
*/
|
||||||
export const updateUsersToolsToken = async (user: User | User['id']): Promise<string> => {
|
export const updateUsersToolsToken = async (user: User | User['id']): Promise<string> => {
|
||||||
const id = typeof user === 'number'
|
const id = typeof user === 'number'
|
||||||
? user
|
? user
|
||||||
: user.id;
|
: user.id;
|
||||||
|
|
||||||
const misshaiToken = await genToken();
|
const misshaiToken = await genToken();
|
||||||
Users.update(id, { misshaiToken });
|
Users.update(id, { misshaiToken });
|
||||||
return misshaiToken;
|
return misshaiToken;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -50,7 +50,7 @@ export const updateUsersToolsToken = async (user: User | User['id']): Promise<st
|
||||||
* @returns ユーザー
|
* @returns ユーザー
|
||||||
*/
|
*/
|
||||||
export const getUserByToolsToken = (token: string): Promise<IUser | undefined> => {
|
export const getUserByToolsToken = (token: string): Promise<IUser | undefined> => {
|
||||||
return Users.findOne({ misshaiToken: token }).then(packUser);
|
return Users.findOne({ misshaiToken: token }).then(packUser);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -60,13 +60,13 @@ export const getUserByToolsToken = (token: string): Promise<IUser | undefined> =
|
||||||
* @param token トークン
|
* @param token トークン
|
||||||
*/
|
*/
|
||||||
export const upsertUser = async (username: string, host: string, token: string): Promise<void> => {
|
export const upsertUser = async (username: string, host: string, token: string): Promise<void> => {
|
||||||
const u = await getUser(username, host);
|
const u = await getUser(username, host);
|
||||||
if (u) {
|
if (u) {
|
||||||
await Users.update(u.id, { token, tokenVersion: currentTokenVersion });
|
await Users.update(u.id, { token, tokenVersion: currentTokenVersion });
|
||||||
} else {
|
} else {
|
||||||
const result = await Users.save({ username, host, token, tokenVersion: currentTokenVersion });
|
const result = await Users.save({ username, host, token, tokenVersion: currentTokenVersion });
|
||||||
await updateUsersToolsToken(result.id);
|
await updateUsersToolsToken(result.id);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -76,7 +76,7 @@ export const upsertUser = async (username: string, host: string, token: string):
|
||||||
* @param record 既存のユーザー情報
|
* @param record 既存のユーザー情報
|
||||||
*/
|
*/
|
||||||
export const updateUser = async (username: string, host: string, record: DeepPartial<User>): Promise<void> => {
|
export const updateUser = async (username: string, host: string, record: DeepPartial<User>): Promise<void> => {
|
||||||
await Users.update({ username, host }, record);
|
await Users.update({ username, host }, record);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -85,7 +85,7 @@ export const updateUser = async (username: string, host: string, record: DeepPar
|
||||||
* @param host ホスト名
|
* @param host ホスト名
|
||||||
*/
|
*/
|
||||||
export const deleteUser = async (username: string, host: string): Promise<void> => {
|
export const deleteUser = async (username: string, host: string): Promise<void> => {
|
||||||
await Users.delete({ username, host });
|
await Users.delete({ username, host });
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -93,5 +93,5 @@ export const deleteUser = async (username: string, host: string): Promise<void>
|
||||||
* @returns ユーザー数
|
* @returns ユーザー数
|
||||||
*/
|
*/
|
||||||
export const getUserCount = (): Promise<number> => {
|
export const getUserCount = (): Promise<number> => {
|
||||||
return Users.count();
|
return Users.count();
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
export interface Count
|
export interface Count
|
||||||
{
|
{
|
||||||
notesCount: number;
|
notesCount: number;
|
||||||
followingCount: number;
|
followingCount: number;
|
||||||
followersCount: number;
|
followersCount: number;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,31 +1,31 @@
|
||||||
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
|
import {Entity, PrimaryGeneratedColumn, Column} from 'typeorm';
|
||||||
import { IAnnouncement } from '../../../common/types/announcement';
|
import {IAnnouncement} from '../../../common/types/announcement.js';
|
||||||
|
|
||||||
@Entity()
|
@Entity()
|
||||||
export class Announcement implements IAnnouncement {
|
export class Announcement implements IAnnouncement {
|
||||||
@PrimaryGeneratedColumn()
|
@PrimaryGeneratedColumn()
|
||||||
public id: number;
|
public id: number;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
type: 'timestamp without time zone',
|
type: 'timestamp without time zone',
|
||||||
})
|
})
|
||||||
public createdAt: Date;
|
public createdAt: Date;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
type: 'varchar',
|
type: 'varchar',
|
||||||
length: 128,
|
length: 128,
|
||||||
})
|
})
|
||||||
public title: string;
|
public title: string;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
type: 'varchar',
|
type: 'varchar',
|
||||||
length: 8192,
|
length: 8192,
|
||||||
})
|
})
|
||||||
public body: string;
|
public body: string;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
type: 'integer',
|
type: 'integer',
|
||||||
default: 0,
|
default: 0,
|
||||||
})
|
})
|
||||||
public like: number;
|
public like: number;
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,8 +3,8 @@ import { Entity, Index, PrimaryColumn } from 'typeorm';
|
||||||
@Entity()
|
@Entity()
|
||||||
@Index([ 'token' ], { unique: true })
|
@Index([ 'token' ], { unique: true })
|
||||||
export class UsedToken {
|
export class UsedToken {
|
||||||
@PrimaryColumn({
|
@PrimaryColumn({
|
||||||
type: 'varchar'
|
type: 'varchar'
|
||||||
})
|
})
|
||||||
public token: string;
|
public token: string;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,114 +1,114 @@
|
||||||
import { Entity, Column, PrimaryGeneratedColumn, Index } from 'typeorm';
|
import { Entity, Column, PrimaryGeneratedColumn, Index } from 'typeorm';
|
||||||
import { AlertMode, alertModes } from '../../../common/types/alert-mode';
|
import { AlertMode, alertModes } from '../../../common/types/alert-mode.js';
|
||||||
import { visibilities, Visibility } from '../../../common/types/visibility';
|
import { visibilities, Visibility } from '../../../common/types/visibility.js';
|
||||||
import { IUser } from '../../../common/types/user';
|
import { IUser } from '../../../common/types/user.js';
|
||||||
|
|
||||||
@Entity()
|
@Entity()
|
||||||
@Index(['username', 'host'], { unique: true })
|
@Index(['username', 'host'], { unique: true })
|
||||||
export class User implements IUser {
|
export class User implements IUser {
|
||||||
@PrimaryGeneratedColumn()
|
@PrimaryGeneratedColumn()
|
||||||
public id: number;
|
public id: number;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
type: 'varchar'
|
type: 'varchar'
|
||||||
})
|
})
|
||||||
public username: string;
|
public username: string;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
type: 'varchar'
|
type: 'varchar'
|
||||||
})
|
})
|
||||||
public host: string;
|
public host: string;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
type: 'varchar'
|
type: 'varchar'
|
||||||
})
|
})
|
||||||
public token: string;
|
public token: string;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
type: 'varchar',
|
type: 'varchar',
|
||||||
default: ''
|
default: ''
|
||||||
})
|
})
|
||||||
public misshaiToken: string;
|
public misshaiToken: string;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
type: 'integer',
|
type: 'integer',
|
||||||
default: 0,
|
default: 0,
|
||||||
})
|
})
|
||||||
public prevNotesCount: number;
|
public prevNotesCount: number;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
type: 'integer',
|
type: 'integer',
|
||||||
default: 0,
|
default: 0,
|
||||||
})
|
})
|
||||||
public prevFollowingCount: number;
|
public prevFollowingCount: number;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
type: 'integer',
|
type: 'integer',
|
||||||
default: 0,
|
default: 0,
|
||||||
})
|
})
|
||||||
public prevFollowersCount: number;
|
public prevFollowersCount: number;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
type: 'enum',
|
type: 'enum',
|
||||||
enum: alertModes,
|
enum: alertModes,
|
||||||
default: 'notification'
|
default: 'notification'
|
||||||
})
|
})
|
||||||
public alertMode: AlertMode;
|
public alertMode: AlertMode;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
type: 'enum',
|
type: 'enum',
|
||||||
enum: visibilities,
|
enum: visibilities,
|
||||||
default: 'home',
|
default: 'home',
|
||||||
})
|
})
|
||||||
public visibility: Visibility;
|
public visibility: Visibility;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
default: false,
|
default: false,
|
||||||
})
|
})
|
||||||
public localOnly: boolean;
|
public localOnly: boolean;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
default: false,
|
default: false,
|
||||||
})
|
})
|
||||||
public remoteFollowersOnly: boolean;
|
public remoteFollowersOnly: boolean;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
type: 'varchar',
|
type: 'varchar',
|
||||||
length: 1024,
|
length: 1024,
|
||||||
nullable: true,
|
nullable: true,
|
||||||
})
|
})
|
||||||
public template: string | null;
|
public template: string | null;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
type: 'real',
|
type: 'real',
|
||||||
default: 0,
|
default: 0,
|
||||||
})
|
})
|
||||||
public prevRating: number;
|
public prevRating: number;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
type: 'real',
|
type: 'real',
|
||||||
default: 0,
|
default: 0,
|
||||||
})
|
})
|
||||||
public rating: number;
|
public rating: number;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
default: false,
|
default: false,
|
||||||
})
|
})
|
||||||
public useRanking: boolean;
|
public useRanking: boolean;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
default: false,
|
default: false,
|
||||||
})
|
})
|
||||||
public bannedFromRanking: boolean;
|
public bannedFromRanking: boolean;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
type: 'integer',
|
type: 'integer',
|
||||||
default: 1,
|
default: 1,
|
||||||
comment: 'Misskey API トークンのバージョン。現行と違う場合はアップデートを要求する',
|
comment: 'Misskey API トークンのバージョン。現行と違う場合はアップデートを要求する',
|
||||||
})
|
})
|
||||||
public tokenVersion: number;
|
public tokenVersion: number;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { User } from './entities/user';
|
import { User } from './entities/user.js';
|
||||||
import { UsedToken } from './entities/used-token';
|
import { UsedToken } from './entities/used-token.js';
|
||||||
import { getRepository } from 'typeorm';
|
import { getRepository } from 'typeorm';
|
||||||
import { Announcement } from './entities/announcement';
|
import { Announcement } from './entities/announcement.js';
|
||||||
|
|
||||||
export const Users = getRepository(User);
|
export const Users = getRepository(User);
|
||||||
export const UsedTokens = getRepository(UsedToken);
|
export const UsedTokens = getRepository(UsedToken);
|
||||||
|
|
|
@ -1,7 +1,12 @@
|
||||||
import views from 'koa-views';
|
import views from 'koa-views';
|
||||||
import { version } from '../meta.json';
|
import path from 'path';
|
||||||
|
import url from 'url';
|
||||||
|
|
||||||
|
import { meta } from '../config.js';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
export const render = views(__dirname + '/views', {
|
export const render = views(__dirname + '/views', {
|
||||||
extension: 'pug',
|
extension: 'pug',
|
||||||
options: { version },
|
options: { version: meta.version },
|
||||||
});
|
});
|
||||||
|
|
|
@ -8,12 +8,16 @@ import ms from 'ms';
|
||||||
import striptags from 'striptags';
|
import striptags from 'striptags';
|
||||||
import MarkdownIt from 'markdown-it';
|
import MarkdownIt from 'markdown-it';
|
||||||
|
|
||||||
import { config } from '../config';
|
import { config } from '../config.js';
|
||||||
import { upsertUser, getUser, updateUser } from './functions/users';
|
import { upsertUser, getUser, updateUser } from './functions/users.js';
|
||||||
import { api } from './services/misskey';
|
import { api } from './services/misskey.js';
|
||||||
import { die } from './die';
|
import { die } from './die.js';
|
||||||
import { misskeyAppInfo } from './const';
|
import { misskeyAppInfo } from './const.js';
|
||||||
import { Announcements } from './models';
|
import { Announcements } from './models/index.js';
|
||||||
|
import path from 'path';
|
||||||
|
import url from 'url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
export const router = new Router<DefaultState, Context>();
|
export const router = new Router<DefaultState, Context>();
|
||||||
|
|
||||||
|
@ -23,175 +27,175 @@ const tokenSecretCache: Record<string, string> = {};
|
||||||
const md = new MarkdownIt();
|
const md = new MarkdownIt();
|
||||||
|
|
||||||
router.get('/login', async ctx => {
|
router.get('/login', async ctx => {
|
||||||
let host = ctx.query.host as string | undefined;
|
let host = ctx.query.host as string | undefined;
|
||||||
if (!host) {
|
if (!host) {
|
||||||
await die(ctx, 'invalidParamater');
|
await die(ctx, 'invalidParamater');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// http://, https://を潰す
|
// http://, https://を潰す
|
||||||
host = host.trim().replace(/^https?:\/\//g, '').replace(/\/+/g, '');
|
host = host.trim().replace(/^https?:\/\//g, '').replace(/\/+/g, '');
|
||||||
|
|
||||||
const meta = await api<{ name: string, uri: string, version: string, features: Record<string, boolean | undefined> }>(host, 'meta', {}).catch(async e => {
|
const meta = await api<{ name: string, uri: string, version: string, features: Record<string, boolean | undefined> }>(host, 'meta', {}).catch(async e => {
|
||||||
if (!(e instanceof Error && e.name === 'Error')) throw e;
|
if (!(e instanceof Error && e.name === 'Error')) throw e;
|
||||||
await die(ctx, 'hostNotFound');
|
await die(ctx, 'hostNotFound');
|
||||||
});
|
});
|
||||||
|
|
||||||
// NOTE: catchが呼ばれた場合はvoidとなるためundefinedのはず
|
// NOTE: catchが呼ばれた場合はvoidとなるためundefinedのはず
|
||||||
if (typeof meta === 'undefined') return;
|
if (typeof meta === 'undefined') return;
|
||||||
|
|
||||||
if (typeof meta !== 'object') {
|
if (typeof meta !== 'object') {
|
||||||
await die(ctx, 'other');
|
await die(ctx, 'other');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (meta.version.includes('hitori')) {
|
if (meta.version.includes('hitori')) {
|
||||||
await die(ctx, 'hitorisskeyIsDenied');
|
await die(ctx, 'hitorisskeyIsDenied');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTE:
|
// NOTE:
|
||||||
// 環境によってはアクセスしたドメインとMisskeyにおけるhostが異なるケースがある
|
// 環境によってはアクセスしたドメインとMisskeyにおけるhostが異なるケースがある
|
||||||
// そういったインスタンスにおいてアカウントの不整合が生じるため、
|
// そういったインスタンスにおいてアカウントの不整合が生じるため、
|
||||||
// APIから戻ってきたホスト名を正しいものとして、改めて正規化する
|
// APIから戻ってきたホスト名を正しいものとして、改めて正規化する
|
||||||
host = meta.uri.replace(/^https?:\/\//g, '').replace(/\/+/g, '').trim();
|
host = meta.uri.replace(/^https?:\/\//g, '').replace(/\/+/g, '').trim();
|
||||||
|
|
||||||
const { name, permission, description } = misskeyAppInfo;
|
const { name, permission, description } = misskeyAppInfo;
|
||||||
|
|
||||||
if (meta.features.miauth) {
|
if (meta.features.miauth) {
|
||||||
// MiAuthを使用する
|
// MiAuthを使用する
|
||||||
const callback = encodeURI(`${config.url}/miauth`);
|
const callback = encodeURI(`${config.url}/miauth`);
|
||||||
|
|
||||||
const session = uuid();
|
const session = uuid();
|
||||||
const url = `https://${host}/miauth/${session}?name=${encodeURI(name)}&callback=${encodeURI(callback)}&permission=${encodeURI(permission.join(','))}`;
|
const url = `https://${host}/miauth/${session}?name=${encodeURI(name)}&callback=${encodeURI(callback)}&permission=${encodeURI(permission.join(','))}`;
|
||||||
sessionHostCache[session] = host;
|
sessionHostCache[session] = host;
|
||||||
|
|
||||||
ctx.redirect(url);
|
ctx.redirect(url);
|
||||||
} else {
|
} else {
|
||||||
// 旧型認証を使用する
|
// 旧型認証を使用する
|
||||||
const callbackUrl = encodeURI(`${config.url}/legacy-auth`);
|
const callbackUrl = encodeURI(`${config.url}/legacy-auth`);
|
||||||
|
|
||||||
const { secret } = await api<{ secret: string }>(host, 'app/create', {
|
const { secret } = await api<{ secret: string }>(host, 'app/create', {
|
||||||
name, description, permission, callbackUrl,
|
name, description, permission, callbackUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { token, url } = await api<{ token: string, url: string }>(host, 'auth/session/generate', {
|
const { token, url } = await api<{ token: string, url: string }>(host, 'auth/session/generate', {
|
||||||
appSecret: secret
|
appSecret: secret
|
||||||
});
|
});
|
||||||
|
|
||||||
sessionHostCache[token] = host;
|
sessionHostCache[token] = host;
|
||||||
tokenSecretCache[token] = secret;
|
tokenSecretCache[token] = secret;
|
||||||
|
|
||||||
ctx.redirect(url);
|
ctx.redirect(url);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/teapot', async ctx => {
|
router.get('/teapot', async ctx => {
|
||||||
await die(ctx, 'teapot', 418);
|
await die(ctx, 'teapot', 418);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/miauth', async ctx => {
|
router.get('/miauth', async ctx => {
|
||||||
const session = ctx.query.session as string | undefined;
|
const session = ctx.query.session as string | undefined;
|
||||||
if (!session) {
|
if (!session) {
|
||||||
await die(ctx, 'sessionRequired');
|
await die(ctx, 'sessionRequired');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const host = sessionHostCache[session];
|
const host = sessionHostCache[session];
|
||||||
delete sessionHostCache[session];
|
delete sessionHostCache[session];
|
||||||
if (!host) {
|
if (!host) {
|
||||||
await die(ctx);
|
await die(ctx);
|
||||||
console.error('host is null or undefined');
|
console.error('host is null or undefined');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = `https://${host}/api/miauth/${session}/check`;
|
const url = `https://${host}/api/miauth/${session}/check`;
|
||||||
const res = await axios.post(url, {});
|
const res = await axios.post(url, {});
|
||||||
const { token, user } = res.data;
|
const { token, user } = res.data;
|
||||||
|
|
||||||
if (!token || !user) {
|
if (!token || !user) {
|
||||||
await die(ctx);
|
await die(ctx);
|
||||||
if (!token) console.error('token is null or undefined');
|
if (!token) console.error('token is null or undefined');
|
||||||
if (!user) console.error('user is null or undefined');
|
if (!user) console.error('user is null or undefined');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await login(ctx, user, host, token);
|
await login(ctx, user, host, token);
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/legacy-auth', async ctx => {
|
router.get('/legacy-auth', async ctx => {
|
||||||
const token = ctx.query.token as string | undefined;
|
const token = ctx.query.token as string | undefined;
|
||||||
if (!token) {
|
if (!token) {
|
||||||
await die(ctx, 'tokenRequired');
|
await die(ctx, 'tokenRequired');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const host = sessionHostCache[token];
|
const host = sessionHostCache[token];
|
||||||
delete sessionHostCache[token];
|
delete sessionHostCache[token];
|
||||||
if (!host) {
|
if (!host) {
|
||||||
await die(ctx);
|
await die(ctx);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const appSecret = tokenSecretCache[token];
|
const appSecret = tokenSecretCache[token];
|
||||||
delete tokenSecretCache[token];
|
delete tokenSecretCache[token];
|
||||||
if (!appSecret) {
|
if (!appSecret) {
|
||||||
await die(ctx);
|
await die(ctx);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { accessToken, user } = await api<{ accessToken: string, user: Record<string, unknown> }>(host, 'auth/session/userkey', {
|
const { accessToken, user } = await api<{ accessToken: string, user: Record<string, unknown> }>(host, 'auth/session/userkey', {
|
||||||
appSecret, token,
|
appSecret, token,
|
||||||
});
|
});
|
||||||
const i = crypto.createHash('sha256').update(accessToken + appSecret, 'utf8').digest('hex');
|
const i = crypto.createHash('sha256').update(accessToken + appSecret, 'utf8').digest('hex');
|
||||||
|
|
||||||
await login(ctx, user, host, i);
|
await login(ctx, user, host, i);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/assets/(.*)', async ctx => {
|
router.get('/assets/(.*)', async ctx => {
|
||||||
await koaSend(ctx as any, ctx.path.replace('/assets/', ''), {
|
await koaSend(ctx as any, ctx.path.replace('/assets/', ''), {
|
||||||
root: `${__dirname}/../assets/`,
|
root: `${__dirname}/../assets/`,
|
||||||
maxage: process.env.NODE_ENV !== 'production' ? 0 : ms('7 days'),
|
maxage: process.env.NODE_ENV !== 'production' ? 0 : ms('7 days'),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/api(.*)', async (ctx, next) => {
|
router.get('/api(.*)', async (ctx, next) => {
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/announcements/:id', async (ctx) => {
|
router.get('/announcements/:id', async (ctx) => {
|
||||||
const a = await Announcements.findOne(ctx.params.id);
|
const a = await Announcements.findOne(ctx.params.id);
|
||||||
const stripped = striptags(md.render(a?.body ?? '').replace(/\n/g, ' '));
|
const stripped = striptags(md.render(a?.body ?? '').replace(/\n/g, ' '));
|
||||||
await ctx.render('frontend', a ? {
|
await ctx.render('frontend', a ? {
|
||||||
t: a.title,
|
t: a.title,
|
||||||
d: stripped.length > 80 ? stripped.substring(0, 80) + '…' : stripped,
|
d: stripped.length > 80 ? stripped.substring(0, 80) + '…' : stripped,
|
||||||
} : null);
|
} : null);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/__rescue__', async(ctx) => {
|
router.get('/__rescue__', async(ctx) => {
|
||||||
await ctx.render('rescue');
|
await ctx.render('rescue');
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('(.*)', async (ctx) => {
|
router.get('(.*)', async (ctx) => {
|
||||||
await ctx.render('frontend');
|
await ctx.render('frontend');
|
||||||
});
|
});
|
||||||
|
|
||||||
async function login(ctx: Context, user: Record<string, unknown>, host: string, token: string) {
|
async function login(ctx: Context, user: Record<string, unknown>, host: string, token: string) {
|
||||||
const isNewcomer = !(await getUser(user.username as string, host));
|
const isNewcomer = !(await getUser(user.username as string, host));
|
||||||
await upsertUser(user.username as string, host, token);
|
await upsertUser(user.username as string, host, token);
|
||||||
|
|
||||||
const u = await getUser(user.username as string, host);
|
const u = await getUser(user.username as string, host);
|
||||||
|
|
||||||
if (!u) {
|
if (!u) {
|
||||||
await die(ctx);
|
await die(ctx);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isNewcomer) {
|
if (isNewcomer) {
|
||||||
await updateUser(u.username, u.host, {
|
await updateUser(u.username, u.host, {
|
||||||
prevNotesCount: user.notesCount as number ?? 0,
|
prevNotesCount: user.notesCount as number ?? 0,
|
||||||
prevFollowingCount: user.followingCount as number ?? 0,
|
prevFollowingCount: user.followingCount as number ?? 0,
|
||||||
prevFollowersCount: user.followersCount as number ?? 0,
|
prevFollowersCount: user.followersCount as number ?? 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await ctx.render('frontend', { token: u.misshaiToken });
|
await ctx.render('frontend', { token: u.misshaiToken });
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,45 +1,44 @@
|
||||||
import Koa from 'koa';
|
import Koa from 'koa';
|
||||||
import bodyParser from 'koa-bodyparser';
|
import bodyParser from 'koa-bodyparser';
|
||||||
import { Action, useKoaServer } from 'routing-controllers';
|
import {Action, useKoaServer} from 'routing-controllers';
|
||||||
|
|
||||||
import { config } from '../config';
|
import {config, meta} from '../config.js';
|
||||||
import { render } from './render';
|
import {render} from './render.js';
|
||||||
import { router } from './router';
|
import {router} from './router.js';
|
||||||
import { getUserByToolsToken } from './functions/users';
|
import {getUserByToolsToken} from './functions/users.js';
|
||||||
import { version } from '../meta.json';
|
import controllers from './controllers/index.js';
|
||||||
|
|
||||||
import 'reflect-metadata';
|
import 'reflect-metadata';
|
||||||
|
|
||||||
export default (): void => {
|
export default (): void => {
|
||||||
const app = new Koa();
|
const app = new Koa();
|
||||||
|
|
||||||
console.log('Misskey Tools v' + version);
|
console.log('Misskey Tools v' + meta.version);
|
||||||
|
|
||||||
console.log('Initializing DB connection...');
|
console.log('Initializing DB connection...');
|
||||||
|
|
||||||
app.use(render);
|
app.use(render);
|
||||||
app.use(bodyParser());
|
app.use(bodyParser());
|
||||||
|
|
||||||
useKoaServer(app, {
|
useKoaServer(app, {
|
||||||
controllers: [__dirname + '/controllers/**/*{.ts,.js}'],
|
controllers,
|
||||||
routePrefix: '/api/v1',
|
routePrefix: '/api/v1',
|
||||||
classTransformer: true,
|
classTransformer: true,
|
||||||
validation: true,
|
validation: true,
|
||||||
currentUserChecker: async ({ request }: Action) => {
|
currentUserChecker: async ({ request }: Action) => {
|
||||||
const { authorization } = request.header;
|
const { authorization } = request.header;
|
||||||
if (!authorization || !authorization.startsWith('Bearer ')) return null;
|
if (!authorization || !authorization.startsWith('Bearer ')) return null;
|
||||||
|
|
||||||
const token = authorization.split(' ')[1].trim();
|
const token = authorization.split(' ')[1].trim();
|
||||||
const user = await getUserByToolsToken(token);
|
return await getUserByToolsToken(token);
|
||||||
return user;
|
},
|
||||||
},
|
});
|
||||||
});
|
|
||||||
|
|
||||||
app.use(router.routes());
|
app.use(router.routes());
|
||||||
app.use(router.allowedMethods());
|
app.use(router.allowedMethods());
|
||||||
|
|
||||||
console.log(`listening port ${config.port}...`);
|
console.log(`listening port ${config.port}...`);
|
||||||
console.log('App launched!');
|
console.log('App launched!');
|
||||||
|
|
||||||
app.listen(config.port || 3000);
|
app.listen(config.port || 3000);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,13 +1,13 @@
|
||||||
import { getConnection, createConnection, Connection } from 'typeorm';
|
import { getConnection, createConnection, Connection } from 'typeorm';
|
||||||
import { config } from '../../config';
|
import { config } from '../../config.js';
|
||||||
import { User } from '../models/entities/user';
|
import { User } from '../models/entities/user.js';
|
||||||
import { UsedToken } from '../models/entities/used-token';
|
import { UsedToken } from '../models/entities/used-token.js';
|
||||||
import { Announcement } from '../models/entities/announcement';
|
import { Announcement } from '../models/entities/announcement.js';
|
||||||
|
|
||||||
export const entities = [
|
export const entities = [
|
||||||
User,
|
User,
|
||||||
UsedToken,
|
UsedToken,
|
||||||
Announcement,
|
Announcement,
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -16,26 +16,26 @@ export const entities = [
|
||||||
* @returns 取得または作成したDBコネクション
|
* @returns 取得または作成したDBコネクション
|
||||||
*/
|
*/
|
||||||
export const initDb = async (force = false): Promise<Connection> => {
|
export const initDb = async (force = false): Promise<Connection> => {
|
||||||
// forceがtrueでない限り、既に接続が存在する場合はそれを返す
|
// forceがtrueでない限り、既に接続が存在する場合はそれを返す
|
||||||
if (!force) {
|
if (!force) {
|
||||||
try {
|
try {
|
||||||
const conn = getConnection();
|
const conn = getConnection();
|
||||||
return Promise.resolve(conn);
|
return Promise.resolve(conn);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// noop
|
// noop
|
||||||
console.warn('connection is not found, so create');
|
console.warn('connection is not found, so create');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 接続がないか、forceがtrueの場合は新規作成する
|
// 接続がないか、forceがtrueの場合は新規作成する
|
||||||
return createConnection({
|
return createConnection({
|
||||||
type: 'postgres',
|
type: 'postgres',
|
||||||
host: config.db.host,
|
host: config.db.host,
|
||||||
port: config.db.port,
|
port: config.db.port,
|
||||||
username: config.db.user,
|
username: config.db.user,
|
||||||
password: config.db.pass,
|
password: config.db.pass,
|
||||||
database: config.db.db,
|
database: config.db.db,
|
||||||
extra: config.db.extra,
|
extra: config.db.extra,
|
||||||
entities,
|
entities,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import {printLog} from '../store';
|
import {printLog} from '../store.js';
|
||||||
import {delay} from '../utils/delay';
|
import {delay} from '../utils/delay.js';
|
||||||
|
|
||||||
export const ua = `Mozilla/5.0 MisskeyTools +https://github.com/shrimpia/misskey-tools Node/${process.version}`;
|
export const ua = `Mozilla/5.0 MisskeyTools +https://github.com/shrimpia/misskey-tools Node/${process.version}`;
|
||||||
|
|
||||||
|
@ -10,26 +10,26 @@ const RETRY_COUNT = 5;
|
||||||
* Misskey APIを呼び出す
|
* Misskey APIを呼び出す
|
||||||
*/
|
*/
|
||||||
export const api = async <T extends Record<string, unknown> = Record<string, unknown>>(host: string, endpoint: string, arg: Record<string, unknown>, token?: string): Promise<T> => {
|
export const api = async <T extends Record<string, unknown> = Record<string, unknown>>(host: string, endpoint: string, arg: Record<string, unknown>, token?: string): Promise<T> => {
|
||||||
const a = { ...arg };
|
const a = { ...arg };
|
||||||
if (token) {
|
if (token) {
|
||||||
a.i = token;
|
a.i = token;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < RETRY_COUNT; i++) {
|
for (let i = 0; i < RETRY_COUNT; i++) {
|
||||||
let data: T;
|
let data: T;
|
||||||
try {
|
try {
|
||||||
data = await axios.post<T>(`https://${host}/api/${endpoint}`, a).then(res => res.data);
|
data = await axios.post<T>(`https://${host}/api/${endpoint}`, a).then(res => res.data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
printLog(`接続エラー: ${host}/api/${endpoint} リトライ中 (${i + 1} / ${RETRY_COUNT})\n${e}`, 'error');
|
printLog(`接続エラー: ${host}/api/${endpoint} リトライ中 (${i + 1} / ${RETRY_COUNT})\n${e}`, 'error');
|
||||||
await delay(3000);
|
await delay(3000);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!('error' in data)) {
|
if (!(typeof data === 'object' && 'error' in data)) {
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
throw new MisskeyError((data as any).error);
|
throw new MisskeyError((data as any).error);
|
||||||
}
|
}
|
||||||
throw new TimedOutError();
|
throw new TimedOutError();
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -39,25 +39,25 @@ export const api = async <T extends Record<string, unknown> = Record<string, unk
|
||||||
* @returns トークンが有効ならtrue、無効ならfalse
|
* @returns トークンが有効ならtrue、無効ならfalse
|
||||||
*/
|
*/
|
||||||
export const apiAvailable = async (host: string, i: string): Promise<boolean> => {
|
export const apiAvailable = async (host: string, i: string): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const res = await api(host, 'i', {}, i);
|
const res = await api(host, 'i', {}, i);
|
||||||
return !res.error;
|
return !res.error;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export class TimedOutError extends Error {}
|
export class TimedOutError extends Error {}
|
||||||
|
|
||||||
export class MisskeyError extends Error {
|
export class MisskeyError extends Error {
|
||||||
constructor(public error: MisskeyErrorObject) {
|
constructor(public error: MisskeyErrorObject) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MisskeyErrorObject {
|
export interface MisskeyErrorObject {
|
||||||
message: string;
|
message: string;
|
||||||
code: string;
|
code: string;
|
||||||
id: string;
|
id: string;
|
||||||
kind: string;
|
kind: string;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { User } from '../models/entities/user';
|
import { User } from '../models/entities/user.js';
|
||||||
import { api } from './misskey';
|
import { api } from './misskey.js';
|
||||||
import {format} from '../../common/functions/format';
|
import {format} from '../../common/functions/format.js';
|
||||||
import {getScores} from '../functions/get-scores';
|
import {getScores} from '../functions/get-scores.js';
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -9,21 +9,21 @@ import {getScores} from '../functions/get-scores';
|
||||||
* @param user ユーザー
|
* @param user ユーザー
|
||||||
*/
|
*/
|
||||||
export const sendAlert = async (user: User) => {
|
export const sendAlert = async (user: User) => {
|
||||||
const text = format(user, await getScores(user));
|
const text = format(user, await getScores(user));
|
||||||
switch (user.alertMode) {
|
switch (user.alertMode) {
|
||||||
case 'note':
|
case 'note':
|
||||||
await sendNoteAlert(text, user);
|
await sendNoteAlert(text, user);
|
||||||
break;
|
break;
|
||||||
case 'notification':
|
case 'notification':
|
||||||
await sendNotificationAlert(text, user);
|
await sendNotificationAlert(text, user);
|
||||||
break;
|
break;
|
||||||
case 'both':
|
case 'both':
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
sendNotificationAlert(text, user),
|
sendNotificationAlert(text, user),
|
||||||
sendNoteAlert(text, user),
|
sendNoteAlert(text, user),
|
||||||
]);
|
]);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -32,16 +32,16 @@ export const sendAlert = async (user: User) => {
|
||||||
* @param user ユーザー
|
* @param user ユーザー
|
||||||
*/
|
*/
|
||||||
export const sendNoteAlert = async (text: string, user: User) => {
|
export const sendNoteAlert = async (text: string, user: User) => {
|
||||||
const res = await api<Record<string, unknown>>(user.host, 'notes/create', {
|
const res = await api<Record<string, unknown>>(user.host, 'notes/create', {
|
||||||
text,
|
text,
|
||||||
visibility: user.visibility,
|
visibility: user.visibility,
|
||||||
localOnly: user.localOnly,
|
localOnly: user.localOnly,
|
||||||
remoteFollowersOnly: user.remoteFollowersOnly,
|
remoteFollowersOnly: user.remoteFollowersOnly,
|
||||||
}, user.token);
|
}, user.token);
|
||||||
|
|
||||||
if (res.error) {
|
if (res.error) {
|
||||||
throw res.error || res;
|
throw res.error || res;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -50,13 +50,13 @@ export const sendNoteAlert = async (text: string, user: User) => {
|
||||||
* @param user ユーザー
|
* @param user ユーザー
|
||||||
*/
|
*/
|
||||||
export const sendNotificationAlert = async (text: string, user: User) => {
|
export const sendNotificationAlert = async (text: string, user: User) => {
|
||||||
const res = await api(user.host, 'notifications/create', {
|
const res = await api(user.host, 'notifications/create', {
|
||||||
header: 'Misskey Tools',
|
header: 'Misskey Tools',
|
||||||
icon: 'https://i.imgur.com/B991yTl.png',
|
icon: 'https://i.imgur.com/B991yTl.png',
|
||||||
body: text,
|
body: text,
|
||||||
}, user.token);
|
}, user.token);
|
||||||
|
|
||||||
if (res.error) {
|
if (res.error) {
|
||||||
throw res.error || res;
|
throw res.error || res;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,18 +1,19 @@
|
||||||
import cron from 'node-cron';
|
import cron from 'node-cron';
|
||||||
import { deleteUser } from '../functions/users';
|
import { deleteUser } from '../functions/users.js';
|
||||||
import { MiUser, updateScore } from '../functions/update-score';
|
import { MiUser, updateScore } from '../functions/update-score.js';
|
||||||
import { updateRating } from '../functions/update-rating';
|
import { updateRating } from '../functions/update-rating.js';
|
||||||
import { Users } from '../models';
|
import { Users } from '../models/index.js';
|
||||||
import {sendNoteAlert, sendNotificationAlert} from './send-alert';
|
import {sendNoteAlert, sendNotificationAlert} from './send-alert.js';
|
||||||
import {api, MisskeyError, TimedOutError} from './misskey';
|
import {api, MisskeyError, TimedOutError} from './misskey.js';
|
||||||
import * as Store from '../store';
|
import * as Store from '../store.js';
|
||||||
import { User } from '../models/entities/user';
|
import { User } from '../models/entities/user.js';
|
||||||
import {groupBy} from '../utils/group-by';
|
import {groupBy} from '../utils/group-by.js';
|
||||||
import {clearLog, printLog} from '../store';
|
import {clearLog, printLog} from '../store.js';
|
||||||
import {errorToString} from '../functions/error-to-string';
|
import {errorToString} from '../functions/error-to-string.js';
|
||||||
import {Acct, toAcct} from '../models/acct';
|
import {Acct, toAcct} from '../models/acct.js';
|
||||||
import {Count} from '../models/count';
|
import {Count} from '../models/count.js';
|
||||||
import {format} from '../../common/functions/format';
|
import {format} from '../../common/functions/format.js';
|
||||||
|
import {delay} from '../utils/delay.js';
|
||||||
|
|
||||||
const ERROR_CODES_USER_REMOVED = ['NO_SUCH_USER', 'AUTHENTICATION_FAILED', 'YOUR_ACCOUNT_SUSPENDED'];
|
const ERROR_CODES_USER_REMOVED = ['NO_SUCH_USER', 'AUTHENTICATION_FAILED', 'YOUR_ACCOUNT_SUSPENDED'];
|
||||||
|
|
||||||
|
@ -20,106 +21,109 @@ const ERROR_CODES_USER_REMOVED = ['NO_SUCH_USER', 'AUTHENTICATION_FAILED', 'YOUR
|
||||||
const userScoreCache = new Map<Acct, Count>();
|
const userScoreCache = new Map<Acct, Count>();
|
||||||
|
|
||||||
export default (): void => {
|
export default (): void => {
|
||||||
cron.schedule('0 0 0 * * *', work);
|
cron.schedule('0 0 0 * * *', work);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const work = async () => {
|
export const work = async () => {
|
||||||
Store.dispatch({ nowCalculating: true });
|
Store.dispatch({ nowCalculating: true });
|
||||||
|
|
||||||
clearLog();
|
clearLog();
|
||||||
printLog('Started.');
|
printLog('Started.');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const users = await Users.find();
|
const users = await Users.find();
|
||||||
const groupedUsers = groupBy(users, u => u.host);
|
const groupedUsers = groupBy(users, u => u.host);
|
||||||
|
|
||||||
printLog(`${users.length} アカウントのレート計算を開始します。`);
|
printLog(`${users.length} アカウントのレート計算を開始します。`);
|
||||||
await calculateAllRating(groupedUsers);
|
await calculateAllRating(groupedUsers);
|
||||||
Store.dispatch({ nowCalculating: false });
|
Store.dispatch({ nowCalculating: false });
|
||||||
|
|
||||||
printLog(`${users.length} アカウントのアラート送信を開始します。`);
|
printLog(`${users.length} アカウントのアラート送信を開始します。`);
|
||||||
await sendAllAlerts(groupedUsers);
|
await sendAllAlerts(groupedUsers);
|
||||||
|
|
||||||
printLog('ミス廃アラートワーカーは正常に完了しました。');
|
printLog('ミス廃アラートワーカーは正常に完了しました。');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
printLog('ミス廃アラートワーカーが異常終了しました。', 'error');
|
printLog('ミス廃アラートワーカーが異常終了しました。', 'error');
|
||||||
printLog(e instanceof Error ? errorToString(e) : e, 'error');
|
printLog(e instanceof Error ? errorToString(e) : JSON.stringify(e, null, ' '), 'error');
|
||||||
} finally {
|
} finally {
|
||||||
Store.dispatch({ nowCalculating: false });
|
Store.dispatch({ nowCalculating: false });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const calculateAllRating = async (groupedUsers: [string, User[]][]) => {
|
const calculateAllRating = async (groupedUsers: [string, User[]][]) => {
|
||||||
return await Promise.all(groupedUsers.map(kv => calculateRating(...kv)));
|
return await Promise.all(groupedUsers.map(kv => calculateRating(...kv)));
|
||||||
};
|
};
|
||||||
|
|
||||||
const calculateRating = async (host: string, users: User[]) => {
|
const calculateRating = async (host: string, users: User[]) => {
|
||||||
for (const user of users) {
|
for (const user of users) {
|
||||||
let miUser: MiUser;
|
let miUser: MiUser;
|
||||||
try {
|
try {
|
||||||
miUser = await api<MiUser>(user.host, 'i', {}, user.token);
|
miUser = await api<MiUser>(user.host, 'i', {}, user.token);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!(e instanceof Error)) {
|
if (!(e instanceof Error)) {
|
||||||
printLog('バグ:エラーオブジェクトはErrorを継承していないといけない', 'error');
|
printLog('バグ:エラーオブジェクトはErrorを継承していないといけない', 'error');
|
||||||
} else if (e instanceof MisskeyError) {
|
} else if (e instanceof MisskeyError) {
|
||||||
if (ERROR_CODES_USER_REMOVED.includes(e.error.code)) {
|
if (ERROR_CODES_USER_REMOVED.includes(e.error.code)) {
|
||||||
// ユーザーが削除されている場合、レコードからも消してとりやめ
|
// ユーザーが削除されている場合、レコードからも消してとりやめ
|
||||||
printLog(`アカウント ${toAcct(user)} は削除されているか、凍結されているか、トークンを失効しています。そのため、本システムからアカウントを削除します。`, 'warn');
|
printLog(`アカウント ${toAcct(user)} は削除されているか、凍結されているか、トークンを失効しています。そのため、本システムからアカウントを削除します。`, 'warn');
|
||||||
await deleteUser(user.username, user.host);
|
await deleteUser(user.username, user.host);
|
||||||
} else {
|
} else {
|
||||||
printLog(`Misskey エラー: ${JSON.stringify(e.error)}`, 'error');
|
printLog(`Misskey エラー: ${JSON.stringify(e.error)}`, 'error');
|
||||||
}
|
}
|
||||||
} else if (e instanceof TimedOutError) {
|
} else if (e instanceof TimedOutError) {
|
||||||
printLog(`サーバー ${user.host} との接続に失敗したため、このサーバーのレート計算を中断します。`, 'error');
|
printLog(`サーバー ${user.host} との接続に失敗したため、このサーバーのレート計算を中断します。`, 'error');
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
// おそらく通信エラー
|
// おそらく通信エラー
|
||||||
printLog(`不明なエラーが発生しました。\n${errorToString(e)}`, 'error');
|
printLog(`不明なエラーが発生しました。\n${errorToString(e)}`, 'error');
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
userScoreCache.set(toAcct(user), miUser);
|
userScoreCache.set(toAcct(user), miUser);
|
||||||
|
|
||||||
await updateRating(user, miUser);
|
await updateRating(user, miUser);
|
||||||
}
|
}
|
||||||
printLog(`${host} ユーザー(${users.length}人) のレート計算が完了しました。`);
|
printLog(`${host} ユーザー(${users.length}人) のレート計算が完了しました。`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const sendAllAlerts = async (groupedUsers: [string, User[]][]) => {
|
const sendAllAlerts = async (groupedUsers: [string, User[]][]) => {
|
||||||
return await Promise.all(groupedUsers.map(kv => sendAlerts(...kv)));
|
return await Promise.all(groupedUsers.map(kv => sendAlerts(...kv)));
|
||||||
};
|
};
|
||||||
|
|
||||||
const sendAlerts = async (host: string, users: User[]) => {
|
const sendAlerts = async (host: string, users: User[]) => {
|
||||||
const models = users
|
const models = users
|
||||||
.map(user => {
|
.map(user => {
|
||||||
const count = userScoreCache.get(toAcct(user));
|
const count = userScoreCache.get(toAcct(user));
|
||||||
if (count == null) return null;
|
if (count == null) return null;
|
||||||
return {
|
return {
|
||||||
user,
|
user,
|
||||||
count,
|
count,
|
||||||
message: format(user, count),
|
message: format(user, count),
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter(u => u != null) as {user: User, count: Count, message: string}[];
|
.filter(u => u != null) as {user: User, count: Count, message: string}[];
|
||||||
|
|
||||||
// 何もしない
|
// 何もしない
|
||||||
for (const {user, count} of models.filter(m => m.user.alertMode === 'nothing')) {
|
for (const {user, count} of models.filter(m => m.user.alertMode === 'nothing')) {
|
||||||
await updateScore(user, count);
|
await updateScore(user, count);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 通知
|
// 通知
|
||||||
for (const {user, count, message} of models.filter(m => m.user.alertMode === 'notification' || m.user.alertMode === 'both')) {
|
for (const {user, count, message} of models.filter(m => m.user.alertMode === 'notification' || m.user.alertMode === 'both')) {
|
||||||
await sendNotificationAlert(message, user);
|
await sendNotificationAlert(message, user);
|
||||||
if (user.alertMode === 'notification') {
|
if (user.alertMode === 'notification') {
|
||||||
await updateScore(user, count);
|
await updateScore(user, count);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 通知
|
// アラート
|
||||||
for (const {user, count, message} of models.filter(m => m.user.alertMode === 'note')) {
|
for (const {user, count, message} of models.filter(m => m.user.alertMode === 'note' || m.user.alertMode === 'both')) {
|
||||||
await sendNoteAlert(message, user);
|
await sendNoteAlert(message, user);
|
||||||
await updateScore(user, count);
|
await Promise.all([
|
||||||
}
|
updateScore(user, count),
|
||||||
|
delay(1000),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
printLog(`${host} ユーザー(${users.length}人) へのアラート送信が完了しました。`);
|
printLog(`${host} ユーザー(${users.length}人) へのアラート送信が完了しました。`);
|
||||||
};
|
};
|
||||||
|
|
|
@ -2,14 +2,14 @@
|
||||||
// getStateを介してステートを取得し、dispatchによって更新する
|
// getStateを介してステートを取得し、dispatchによって更新する
|
||||||
// stateを直接編集できないようになっている
|
// stateを直接編集できないようになっている
|
||||||
|
|
||||||
import {Log} from '../common/types/log';
|
import {Log} from '../common/types/log.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 初期値
|
* 初期値
|
||||||
*/
|
*/
|
||||||
const defaultState: State = {
|
const defaultState: State = {
|
||||||
nowCalculating: false,
|
nowCalculating: false,
|
||||||
misshaiWorkerLog: [],
|
misshaiWorkerLog: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
let _state: Readonly<State> = defaultState;
|
let _state: Readonly<State> = defaultState;
|
||||||
|
@ -18,8 +18,8 @@ let _state: Readonly<State> = defaultState;
|
||||||
* ステートの型
|
* ステートの型
|
||||||
*/
|
*/
|
||||||
export type State = {
|
export type State = {
|
||||||
nowCalculating: boolean,
|
nowCalculating: boolean,
|
||||||
misshaiWorkerLog: Log[],
|
misshaiWorkerLog: Log[],
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -33,19 +33,20 @@ export const getState = () => Object.freeze({ ..._state });
|
||||||
* @param mutation ステートの一部を更新するためのオブジェクト
|
* @param mutation ステートの一部を更新するためのオブジェクト
|
||||||
*/
|
*/
|
||||||
export const dispatch = (mutation: Partial<State>) => {
|
export const dispatch = (mutation: Partial<State>) => {
|
||||||
_state = {
|
_state = {
|
||||||
..._state,
|
..._state,
|
||||||
...mutation,
|
...mutation,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const clearLog = () => {
|
export const clearLog = () => {
|
||||||
dispatch({ misshaiWorkerLog: [] });
|
dispatch({ misshaiWorkerLog: [] });
|
||||||
};
|
};
|
||||||
|
|
||||||
export const printLog = (log: unknown, level: Log['level'] = 'info') => {
|
export const printLog = (log: unknown, level: Log['level'] = 'info') => {
|
||||||
dispatch({ misshaiWorkerLog: [
|
dispatch({ misshaiWorkerLog: [
|
||||||
...getState().misshaiWorkerLog,
|
...getState().misshaiWorkerLog,
|
||||||
{ text: String(log), level, timestamp: new Date() },
|
{ text: String(log), level, timestamp: new Date() },
|
||||||
] });
|
] });
|
||||||
|
console[level](log);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,13 +1,13 @@
|
||||||
type GetKeyFunction<K extends PropertyKey, V> = (cur: V, idx: number, src: readonly V[]) => K;
|
type GetKeyFunction<K extends PropertyKey, V> = (cur: V, idx: number, src: readonly V[]) => K;
|
||||||
|
|
||||||
export const groupBy = <K extends PropertyKey, V>(array: readonly V[], getKey: GetKeyFunction<K, V>) => {
|
export const groupBy = <K extends PropertyKey, V>(array: readonly V[], getKey: GetKeyFunction<K, V>) => {
|
||||||
return Array.from(
|
return Array.from(
|
||||||
array.reduce((map, cur, idx, src) => {
|
array.reduce((map, cur, idx, src) => {
|
||||||
const key = getKey(cur, idx, src);
|
const key = getKey(cur, idx, src);
|
||||||
const list = map.get(key);
|
const list = map.get(key);
|
||||||
if (list) list.push(cur);
|
if (list) list.push(cur);
|
||||||
else map.set(key, [cur]);
|
else map.set(key, [cur]);
|
||||||
return map;
|
return map;
|
||||||
}, new Map<K, V[]>())
|
}, new Map<K, V[]>())
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
const allKatakana = [
|
const allKatakana = [
|
||||||
...('アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨ'.split('')),
|
...('アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨ'.split('')),
|
||||||
'ウィ', 'ウェ',
|
'ウィ', 'ウェ',
|
||||||
'キャ', 'キュ', 'キョ',
|
'キャ', 'キュ', 'キョ',
|
||||||
'クァ', 'クォ',
|
'クァ', 'クォ',
|
||||||
'シャ', 'シュ', 'ショ',
|
'シャ', 'シュ', 'ショ',
|
||||||
'チャ', 'チュ', 'チョ',
|
'チャ', 'チュ', 'チョ',
|
||||||
'ヒャ', 'ヒュ', 'ヒョ',
|
'ヒャ', 'ヒュ', 'ヒョ',
|
||||||
'ミャ', 'ミュ', 'ミョ'
|
'ミャ', 'ミュ', 'ミョ'
|
||||||
];
|
];
|
||||||
|
|
||||||
const allInfix = [ '', 'ー', 'ッ' ];
|
const allInfix = [ '', 'ー', 'ッ' ];
|
||||||
|
@ -15,11 +15,11 @@ const getRandomKatakana = () => allKatakana[Math.floor(Math.random() * allKataka
|
||||||
const getRandomInfix = () => allInfix[Math.floor(Math.random() * allInfix.length)];
|
const getRandomInfix = () => allInfix[Math.floor(Math.random() * allInfix.length)];
|
||||||
|
|
||||||
export const createGacha = () => {
|
export const createGacha = () => {
|
||||||
return [
|
return [
|
||||||
getRandomKatakana(),
|
getRandomKatakana(),
|
||||||
getRandomInfix(),
|
getRandomInfix(),
|
||||||
getRandomKatakana(),
|
getRandomKatakana(),
|
||||||
getRandomInfix(),
|
getRandomInfix(),
|
||||||
...(new Array(Math.floor(Math.random() * 2 + 1)).fill('').map(() => getRandomKatakana()))
|
...(new Array(Math.floor(Math.random() * 2 + 1)).fill('').map(() => getRandomKatakana()))
|
||||||
].join('');
|
].join('');
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
import { config } from '../../config';
|
import { config } from '../../config.js';
|
||||||
import { Score } from '../types/score';
|
import { Score } from '../types/score.js';
|
||||||
import { defaultTemplate } from '../../backend/const';
|
import { defaultTemplate } from '../../backend/const.js';
|
||||||
import { IUser } from '../types/user';
|
import { IUser } from '../types/user.js';
|
||||||
import { createGacha } from './create-gacha';
|
import { createGacha } from './create-gacha.js';
|
||||||
import {Count} from '../../backend/models/count';
|
import {Count} from '../../backend/models/count.js';
|
||||||
import {getDelta} from '../../backend/functions/get-scores';
|
import {getDelta} from '../../backend/functions/get-scores.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 埋め込み変数の型
|
* 埋め込み変数の型
|
||||||
|
@ -15,20 +15,20 @@ export type Variable = string | ((score: Score, user: IUser) => string);
|
||||||
* 埋め込み可能な変数のリスト
|
* 埋め込み可能な変数のリスト
|
||||||
*/
|
*/
|
||||||
export const variables: Record<string, Variable> = {
|
export const variables: Record<string, Variable> = {
|
||||||
notesCount: score => String(score.notesCount),
|
notesCount: score => String(score.notesCount),
|
||||||
followingCount: score => String(score.followingCount),
|
followingCount: score => String(score.followingCount),
|
||||||
followersCount: score => String(score.followersCount),
|
followersCount: score => String(score.followersCount),
|
||||||
notesDelta: score => String(score.notesDelta),
|
notesDelta: score => String(score.notesDelta),
|
||||||
followingDelta: score => String(score.followingDelta),
|
followingDelta: score => String(score.followingDelta),
|
||||||
followersDelta: score => String(score.followersDelta),
|
followersDelta: score => String(score.followersDelta),
|
||||||
url: config.url,
|
url: config.url,
|
||||||
username: (_, user) => String(user.username),
|
username: (_, user) => String(user.username),
|
||||||
host: (_, user) => String(user.host),
|
host: (_, user) => String(user.host),
|
||||||
rating: (_, user) => String(user.rating),
|
rating: (_, user) => String(user.rating),
|
||||||
gacha: () => createGacha(),
|
gacha: () => createGacha(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const variableRegex = /\{([a-zA-Z0-9_]+?)\}/g;
|
const variableRegex = /\{([a-zA-Z0-9_]+?)}/g;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* スコア情報とユーザー情報からテキストを生成する
|
* スコア情報とユーザー情報からテキストを生成する
|
||||||
|
@ -37,13 +37,13 @@ const variableRegex = /\{([a-zA-Z0-9_]+?)\}/g;
|
||||||
* @returns 生成したテキスト
|
* @returns 生成したテキスト
|
||||||
*/
|
*/
|
||||||
export const format = (user: IUser, count: Count): string => {
|
export const format = (user: IUser, count: Count): string => {
|
||||||
const score: Score = {
|
const score: Score = {
|
||||||
...count,
|
...count,
|
||||||
...getDelta(user, count),
|
...getDelta(user, count),
|
||||||
};
|
};
|
||||||
const template = user.template || defaultTemplate;
|
const template = user.template || defaultTemplate;
|
||||||
return template.replace(variableRegex, (m, name) => {
|
return template.replace(variableRegex, (m, name) => {
|
||||||
const v = variables[name];
|
const v = variables[name];
|
||||||
return !v ? m : typeof v === 'function' ? v(score, user) : v;
|
return !v ? m : typeof v === 'function' ? v(score, user) : v;
|
||||||
}) + '\n\n#misshaialert';
|
}) + '\n\n#misshaialert';
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
export const alertModes = [
|
export const alertModes = [
|
||||||
'note',
|
'note',
|
||||||
'notification',
|
'notification',
|
||||||
'both',
|
'both',
|
||||||
'nothing'
|
'nothing'
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type AlertMode = typeof alertModes[number];
|
export type AlertMode = typeof alertModes[number];
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
export interface IAnnouncement {
|
export interface IAnnouncement {
|
||||||
id: number;
|
id: number;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
like: number;
|
like: number;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,18 +1,18 @@
|
||||||
export const designSystemColors = [
|
export const designSystemColors = [
|
||||||
'red',
|
'red',
|
||||||
'vermilion',
|
'vermilion',
|
||||||
'orange',
|
'orange',
|
||||||
'yellow',
|
'yellow',
|
||||||
'lime',
|
'lime',
|
||||||
'green',
|
'green',
|
||||||
'teal',
|
'teal',
|
||||||
'cyan',
|
'cyan',
|
||||||
'skyblue',
|
'skyblue',
|
||||||
'blue',
|
'blue',
|
||||||
'indigo',
|
'indigo',
|
||||||
'purple',
|
'purple',
|
||||||
'magenta',
|
'magenta',
|
||||||
'pink',
|
'pink',
|
||||||
];
|
];
|
||||||
|
|
||||||
export type DesignSystemColor = typeof designSystemColors[number];
|
export type DesignSystemColor = typeof designSystemColors[number];
|
||||||
|
|
|
@ -1,13 +1,13 @@
|
||||||
export const errorCodes = [
|
export const errorCodes = [
|
||||||
'hitorisskeyIsDenied',
|
'hitorisskeyIsDenied',
|
||||||
'teapot',
|
'teapot',
|
||||||
'sessionRequired',
|
'sessionRequired',
|
||||||
'tokenRequired',
|
'tokenRequired',
|
||||||
'invalidParamater',
|
'invalidParamater',
|
||||||
'notAuthorized',
|
'notAuthorized',
|
||||||
'hostNotFound',
|
'hostNotFound',
|
||||||
'invalidHostFormat',
|
'invalidHostFormat',
|
||||||
'other',
|
'other',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type ErrorCode = typeof errorCodes[number];
|
export type ErrorCode = typeof errorCodes[number];
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
export type Log = {
|
export type Log = {
|
||||||
text: string;
|
text: string;
|
||||||
level: 'error' | 'warn' | 'info';
|
level: 'error' | 'warn' | 'info';
|
||||||
timestamp: Date;
|
timestamp: Date;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
export interface Meta {
|
export interface Meta {
|
||||||
version: string;
|
version: string;
|
||||||
currentTokenVersion: number;
|
currentTokenVersion: number;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
|
|
||||||
export interface Score {
|
export interface Score {
|
||||||
notesCount: number;
|
notesCount: number;
|
||||||
followingCount: number;
|
followingCount: number;
|
||||||
followersCount: number;
|
followersCount: number;
|
||||||
notesDelta: string;
|
notesDelta: string;
|
||||||
followingDelta: string;
|
followingDelta: string;
|
||||||
followersDelta: string;
|
followersDelta: string;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,25 +1,25 @@
|
||||||
import { AlertMode } from './alert-mode';
|
import { AlertMode } from './alert-mode.js';
|
||||||
import { Visibility } from './visibility';
|
import { Visibility } from './visibility.js';
|
||||||
|
|
||||||
export interface IUser {
|
export interface IUser {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
host: string;
|
host: string;
|
||||||
token: string;
|
token: string;
|
||||||
misshaiToken: string;
|
misshaiToken: string;
|
||||||
prevNotesCount: number;
|
prevNotesCount: number;
|
||||||
prevFollowingCount: number;
|
prevFollowingCount: number;
|
||||||
prevFollowersCount: number;
|
prevFollowersCount: number;
|
||||||
alertMode: AlertMode;
|
alertMode: AlertMode;
|
||||||
visibility: Visibility;
|
visibility: Visibility;
|
||||||
localOnly: boolean;
|
localOnly: boolean;
|
||||||
remoteFollowersOnly: boolean;
|
remoteFollowersOnly: boolean;
|
||||||
template: string | null;
|
template: string | null;
|
||||||
prevRating: number;
|
prevRating: number;
|
||||||
rating: number;
|
rating: number;
|
||||||
bannedFromRanking: boolean;
|
bannedFromRanking: boolean;
|
||||||
isAdmin?: boolean;
|
isAdmin?: boolean;
|
||||||
tokenVersion: number;
|
tokenVersion: number;
|
||||||
useRanking: boolean;
|
useRanking: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
export const visibilities = [
|
export const visibilities = [
|
||||||
'public', // パブリック
|
'public', // パブリック
|
||||||
'home', // ホーム
|
'home', // ホーム
|
||||||
'followers', // フォロワー
|
'followers', // フォロワー
|
||||||
'users' // ログインユーザー (Groundpolis 限定)
|
'users' // ログインユーザー (Groundpolis 限定)
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type Visibility = typeof visibilities[number];
|
export type Visibility = typeof visibilities[number];
|
|
@ -1,3 +1,13 @@
|
||||||
|
import path from 'path';
|
||||||
|
import url from 'url';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
export const config = Object.freeze(JSON.parse(fs.readFileSync(__dirname + '/../config.json', 'utf-8')));
|
export const config = Object.freeze(JSON.parse(fs.readFileSync(__dirname + '/../config.json', 'utf-8')));
|
||||||
|
|
||||||
|
export const meta: MetaJson = Object.freeze(JSON.parse(fs.readFileSync(__dirname + '/meta.json', 'utf-8')));
|
||||||
|
|
||||||
|
export type MetaJson = {
|
||||||
|
version: string;
|
||||||
|
};
|
||||||
|
|
|
@ -15,77 +15,77 @@ import {$get} from './misc/api';
|
||||||
import {IUser} from '../common/types/user';
|
import {IUser} from '../common/types/user';
|
||||||
|
|
||||||
const AppInner : React.VFC = () => {
|
const AppInner : React.VFC = () => {
|
||||||
const { data: session } = useGetSessionQuery(undefined);
|
const { data: session } = useGetSessionQuery(undefined);
|
||||||
const $location = useLocation();
|
const $location = useLocation();
|
||||||
|
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
useTheme();
|
useTheme();
|
||||||
|
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
|
|
||||||
const [error, setError] = useState<any>((window as any).__misshaialert?.error);
|
const [error, setError] = useState<any>((window as any).__misshaialert?.error);
|
||||||
|
|
||||||
// ページ遷移がまだされていないかどうか
|
// ページ遷移がまだされていないかどうか
|
||||||
const [isFirstView, setFirstView] = useState(true);
|
const [isFirstView, setFirstView] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isFirstView) {
|
if (isFirstView) {
|
||||||
setFirstView(false);
|
setFirstView(false);
|
||||||
} else if (!isFirstView && error) {
|
} else if (!isFirstView && error) {
|
||||||
setError(null);
|
setError(null);
|
||||||
}
|
}
|
||||||
}, [$location]);
|
}, [$location]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const accounts = JSON.parse(localStorage.getItem(LOCALSTORAGE_KEY_ACCOUNTS) || '[]') as string[];
|
const accounts = JSON.parse(localStorage.getItem(LOCALSTORAGE_KEY_ACCOUNTS) || '[]') as string[];
|
||||||
Promise.all(accounts.map(token => $get<IUser>('session', token))).then(a => dispatch(setAccounts(a as IUser[])));
|
Promise.all(accounts.map(token => $get<IUser>('session', token))).then(a => dispatch(setAccounts(a as IUser[])));
|
||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const qMobile = window.matchMedia(`(max-width: ${BREAKPOINT_SM})`);
|
const qMobile = window.matchMedia(`(max-width: ${BREAKPOINT_SM})`);
|
||||||
const syncMobile = (ev: MediaQueryListEvent) => dispatch(setMobile(ev.matches));
|
const syncMobile = (ev: MediaQueryListEvent) => dispatch(setMobile(ev.matches));
|
||||||
dispatch(setMobile(qMobile.matches));
|
dispatch(setMobile(qMobile.matches));
|
||||||
qMobile.addEventListener('change', syncMobile);
|
qMobile.addEventListener('change', syncMobile);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
qMobile.removeEventListener('change', syncMobile);
|
qMobile.removeEventListener('change', syncMobile);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const TheLayout = session || $location.pathname !== '/' ? GeneralLayout : 'div';
|
const TheLayout = session || $location.pathname !== '/' ? GeneralLayout : 'div';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TheLayout>
|
<TheLayout>
|
||||||
{error ? (
|
{error ? (
|
||||||
<div>
|
<div>
|
||||||
<h1>{t('error')}</h1>
|
<h1>{t('error')}</h1>
|
||||||
<p>{t('_error.sorry')}</p>
|
<p>{t('_error.sorry')}</p>
|
||||||
<p>
|
<p>
|
||||||
{t('_error.additionalInfo')}
|
{t('_error.additionalInfo')}
|
||||||
{t(`_error.${error}`)}
|
{t(`_error.${error}`)}
|
||||||
</p>
|
</p>
|
||||||
<Link to="/" className="btn primary">{t('retry')}</Link>
|
<Link to="/" className="btn primary">{t('retry')}</Link>
|
||||||
</div>
|
</div>
|
||||||
) : <Router />}
|
) : <Router />}
|
||||||
<footer className="text-center pa-5">
|
<footer className="text-center pa-5">
|
||||||
<p>(C)2020-2023 Shrimpia Network</p>
|
<p>(C)2020-2023 Shrimpia Network</p>
|
||||||
<p><span dangerouslySetInnerHTML={{__html: t('disclaimerForMisskeyHq')}} /></p>
|
<p><span dangerouslySetInnerHTML={{__html: t('disclaimerForMisskeyHq')}} /></p>
|
||||||
<p>
|
<p>
|
||||||
<a href="https://xeltica.notion.site/Misskey-Tools-688187fc85de4b7e901055326c7ffe74" target="_blank" rel="noreferrer noopener">
|
<a href="https://xeltica.notion.site/Misskey-Tools-688187fc85de4b7e901055326c7ffe74" target="_blank" rel="noreferrer noopener">
|
||||||
{t('termsOfService')}
|
{t('termsOfService')}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</footer>
|
</footer>
|
||||||
<ModalComponent />
|
<ModalComponent />
|
||||||
</TheLayout>
|
</TheLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const App: React.VFC = () => (
|
export const App: React.VFC = () => (
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<AppInner />
|
<AppInner />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</Provider>
|
</Provider>
|
||||||
);
|
);
|
||||||
|
|
|
@ -43,48 +43,48 @@ const MobileHeader = styled.header`
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const GeneralLayout: React.FC = ({children}) => {
|
export const GeneralLayout: React.FC = ({children}) => {
|
||||||
const { data: session } = useGetSessionQuery(undefined);
|
const { data: session } = useGetSessionQuery(undefined);
|
||||||
const { data: meta } = useGetMetaQuery(undefined);
|
const { data: meta } = useGetMetaQuery(undefined);
|
||||||
const { isMobile, title, isDrawerShown } = useSelector(state => state.screen);
|
const { isMobile, title, isDrawerShown } = useSelector(state => state.screen);
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
|
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container isMobile={isMobile}>
|
<Container isMobile={isMobile}>
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<MobileHeader className="navbar hstack f-middle shadow-2 pl-2">
|
<MobileHeader className="navbar hstack f-middle shadow-2 pl-2">
|
||||||
<button className="btn flat" onClick={() => dispatch(setDrawerShown(!isDrawerShown))}>
|
<button className="btn flat" onClick={() => dispatch(setDrawerShown(!isDrawerShown))}>
|
||||||
<i className="fas fa-bars"></i>
|
<i className="fas fa-bars"></i>
|
||||||
</button>
|
</button>
|
||||||
<h1>{t(title ?? 'title')}</h1>
|
<h1>{t(title ?? 'title')}</h1>
|
||||||
</MobileHeader>
|
</MobileHeader>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<Sidebar className="pa-2">
|
<Sidebar className="pa-2">
|
||||||
<NavigationMenu />
|
<NavigationMenu />
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
)}
|
)}
|
||||||
<Main isMobile={isMobile}>
|
<Main isMobile={isMobile}>
|
||||||
{session && meta && meta.currentTokenVersion !== session.tokenVersion && (
|
{session && meta && meta.currentTokenVersion !== session.tokenVersion && (
|
||||||
<div className="alert bg-danger flex f-middle mb-2">
|
<div className="alert bg-danger flex f-middle mb-2">
|
||||||
<i className="icon fas fa-circle-exclamation"></i>
|
<i className="icon fas fa-circle-exclamation"></i>
|
||||||
{t('shouldUpdateToken')}
|
{t('shouldUpdateToken')}
|
||||||
<a className="btn primary" href={`/login?host=${encodeURIComponent(session.host)}`}>
|
<a className="btn primary" href={`/login?host=${encodeURIComponent(session.host)}`}>
|
||||||
{t('update')}
|
{t('update')}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{children}
|
{children}
|
||||||
</Main>
|
</Main>
|
||||||
</div>
|
</div>
|
||||||
<div className={`drawer-container ${isDrawerShown ? 'active' : ''}`}>
|
<div className={`drawer-container ${isDrawerShown ? 'active' : ''}`}>
|
||||||
<div className="backdrop" onClick={() => dispatch(setDrawerShown(false))}></div>
|
<div className="backdrop" onClick={() => dispatch(setDrawerShown(false))}></div>
|
||||||
<div className="drawer pa-2" onClick={e => e.stopPropagation()}>
|
<div className="drawer pa-2" onClick={e => e.stopPropagation()}>
|
||||||
<NavigationMenu />
|
<NavigationMenu />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -10,22 +10,22 @@ export type HeaderProps = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Header: React.FC<HeaderProps> = ({title}) => {
|
export const Header: React.FC<HeaderProps> = ({title}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data } = useGetSessionQuery(undefined);
|
const { data } = useGetSessionQuery(undefined);
|
||||||
const { isMobile } = useSelector(state => state.screen);
|
const { isMobile } = useSelector(state => state.screen);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="navbar hstack shadow-2 bg-panel rounded _header">
|
<header className="navbar hstack shadow-2 bg-panel rounded _header">
|
||||||
<h1 className="navbar-title text-primary mb-0 text-100">
|
<h1 className="navbar-title text-primary mb-0 text-100">
|
||||||
{<Link to="/">{t('title')}</Link>}
|
{<Link to="/">{t('title')}</Link>}
|
||||||
{title && <> / {title}</>}
|
{title && <> / {title}</>}
|
||||||
</h1>
|
</h1>
|
||||||
{data && (
|
{data && (
|
||||||
<button className="btn flat ml-auto primary">
|
<button className="btn flat ml-auto primary">
|
||||||
<i className="fas fa-circle-user"></i>
|
<i className="fas fa-circle-user"></i>
|
||||||
{!isMobile && <span className="ml-1">{data.username}<span className="text-dimmed">@{data.host}</span></span>}
|
{!isMobile && <span className="ml-1">{data.username}<span className="text-dimmed">@{data.host}</span></span>}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,13 +1,13 @@
|
||||||
import React, { useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import { useSelector } from './store';
|
import { useSelector } from './store';
|
||||||
import {
|
import {
|
||||||
builtinDialogButtonNo,
|
builtinDialogButtonNo,
|
||||||
builtinDialogButtonOk,
|
builtinDialogButtonOk,
|
||||||
builtinDialogButtonYes,
|
builtinDialogButtonYes,
|
||||||
DialogButton,
|
DialogButton,
|
||||||
DialogButtonType,
|
DialogButtonType,
|
||||||
DialogIcon,
|
DialogIcon,
|
||||||
ModalTypeDialog
|
ModalTypeDialog
|
||||||
} from './modal/dialog';
|
} from './modal/dialog';
|
||||||
import { Modal } from './modal/modal';
|
import { Modal } from './modal/modal';
|
||||||
import { useDispatch } from 'react-redux';
|
import { useDispatch } from 'react-redux';
|
||||||
|
@ -15,93 +15,93 @@ import { hideModal } from './store/slices/screen';
|
||||||
import { ModalTypeMenu } from './modal/menu';
|
import { ModalTypeMenu } from './modal/menu';
|
||||||
|
|
||||||
const getButtons = (button: DialogButtonType): DialogButton[] => {
|
const getButtons = (button: DialogButtonType): DialogButton[] => {
|
||||||
if (typeof button === 'object') return button;
|
if (typeof button === 'object') return button;
|
||||||
switch (button) {
|
switch (button) {
|
||||||
case 'ok': return [builtinDialogButtonOk];
|
case 'ok': return [builtinDialogButtonOk];
|
||||||
case 'yesNo': return [builtinDialogButtonYes, builtinDialogButtonNo];
|
case 'yesNo': return [builtinDialogButtonYes, builtinDialogButtonNo];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const dialogIconPattern: Record<DialogIcon, string> = {
|
const dialogIconPattern: Record<DialogIcon, string> = {
|
||||||
error: 'fas fa-circle-xmark text-danger',
|
error: 'fas fa-circle-xmark text-danger',
|
||||||
info: 'fas fa-circle-info text-primary',
|
info: 'fas fa-circle-info text-primary',
|
||||||
question: 'fas fa-circle-question text-primary',
|
question: 'fas fa-circle-question text-primary',
|
||||||
warning: 'fas fa-circle-exclamation text-warning',
|
warning: 'fas fa-circle-exclamation text-warning',
|
||||||
};
|
};
|
||||||
|
|
||||||
const Dialog: React.VFC<{modal: ModalTypeDialog}> = ({modal}) => {
|
const Dialog: React.VFC<{modal: ModalTypeDialog}> = ({modal}) => {
|
||||||
const buttons = getButtons(modal.buttons ?? 'ok');
|
const buttons = getButtons(modal.buttons ?? 'ok');
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
const onClickButton = useCallback((i: number) => {
|
const onClickButton = useCallback((i: number) => {
|
||||||
dispatch(hideModal());
|
dispatch(hideModal());
|
||||||
if (modal.onSelect) {
|
if (modal.onSelect) {
|
||||||
modal.onSelect(i);
|
modal.onSelect(i);
|
||||||
}
|
}
|
||||||
}, [dispatch, modal]);
|
}, [dispatch, modal]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card dialog text-center">
|
<div className="card dialog text-center">
|
||||||
<div className="body">
|
<div className="body">
|
||||||
{modal.icon && <div style={{fontSize: '2rem'}} className={dialogIconPattern[modal.icon]} />}
|
{modal.icon && <div style={{fontSize: '2rem'}} className={dialogIconPattern[modal.icon]} />}
|
||||||
{modal.title && <h1>{modal.title}</h1>}
|
{modal.title && <h1>{modal.title}</h1>}
|
||||||
<p>{modal.message}</p>
|
<p>{modal.message}</p>
|
||||||
<div className="hstack" style={{justifyContent: 'center'}}>
|
<div className="hstack" style={{justifyContent: 'center'}}>
|
||||||
{
|
{
|
||||||
buttons.map((b, i) => (
|
buttons.map((b, i) => (
|
||||||
<button className={`btn ${b.style}`} onClick={() => onClickButton(i)} key={i}>
|
<button className={`btn ${b.style}`} onClick={() => onClickButton(i)} key={i}>
|
||||||
{b.text}
|
{b.text}
|
||||||
</button>
|
</button>
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const Menu: React.VFC<{modal: ModalTypeMenu}> = ({modal}) => {
|
const Menu: React.VFC<{modal: ModalTypeMenu}> = ({modal}) => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-menu-wrapper menu shadow-2" style={{
|
<div className="modal-menu-wrapper menu shadow-2" style={{
|
||||||
transform: `translate(${modal.screenX}px, ${modal.screenY}px)`
|
transform: `translate(${modal.screenX}px, ${modal.screenY}px)`
|
||||||
}}>
|
}}>
|
||||||
{
|
{
|
||||||
modal.items.map((item, i) => (
|
modal.items.map((item, i) => (
|
||||||
<button className={`item ${item.disabled ? 'disabled' : ''} ${item.danger ? 'text-danger' : ''}`} onClick={() => {
|
<button className={`item ${item.disabled ? 'disabled' : ''} ${item.danger ? 'text-danger' : ''}`} onClick={() => {
|
||||||
dispatch(hideModal());
|
dispatch(hideModal());
|
||||||
if (item.onClick) {
|
if (item.onClick) {
|
||||||
item.onClick();
|
item.onClick();
|
||||||
}
|
}
|
||||||
}} key={i}>
|
}} key={i}>
|
||||||
{item.icon && <i className={item.icon} />}
|
{item.icon && <i className={item.icon} />}
|
||||||
{item.name}
|
{item.name}
|
||||||
</button>
|
</button>
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ModalInner = (modal: Modal) => {
|
const ModalInner = (modal: Modal) => {
|
||||||
switch (modal.type) {
|
switch (modal.type) {
|
||||||
case 'dialog': return <Dialog modal={modal} />;
|
case 'dialog': return <Dialog modal={modal} />;
|
||||||
case 'menu': return <Menu modal={modal} />;
|
case 'menu': return <Menu modal={modal} />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ModalComponent: React.VFC = () => {
|
export const ModalComponent: React.VFC = () => {
|
||||||
const shown = useSelector(state => state.screen.modalShown);
|
const shown = useSelector(state => state.screen.modalShown);
|
||||||
const modal = useSelector(state => state.screen.modal);
|
const modal = useSelector(state => state.screen.modal);
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
if (!shown || !modal) return null;
|
if (!shown || !modal) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`modal fade ${modal.type === 'menu' ? 'top-left' : 'darken'}`} onClick={() => dispatch(hideModal())}>
|
<div className={`modal fade ${modal.type === 'menu' ? 'top-left' : 'darken'}`} onClick={() => dispatch(hideModal())}>
|
||||||
<div className="fade up" onClick={(e) => e.stopPropagation()}>
|
<div className="fade up" onClick={(e) => e.stopPropagation()}>
|
||||||
{ ModalInner(modal) }
|
{ ModalInner(modal) }
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -10,16 +10,16 @@ import { MisshaiPage } from './pages/apps/misshai';
|
||||||
import { NekomimiPage } from './pages/apps/avatar-cropper';
|
import { NekomimiPage } from './pages/apps/avatar-cropper';
|
||||||
|
|
||||||
export const Router: React.VFC = () => {
|
export const Router: React.VFC = () => {
|
||||||
return (
|
return (
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route exact path="/" component={IndexPage} />
|
<Route exact path="/" component={IndexPage} />
|
||||||
<Route exact path="/apps/avatar-cropper" component={NekomimiPage} />
|
<Route exact path="/apps/avatar-cropper" component={NekomimiPage} />
|
||||||
<Route exact path="/apps/miss-hai" component={MisshaiPage} />
|
<Route exact path="/apps/miss-hai" component={MisshaiPage} />
|
||||||
<Route exact path="/apps/miss-hai/ranking" component={RankingPage} />
|
<Route exact path="/apps/miss-hai/ranking" component={RankingPage} />
|
||||||
<Route exact path="/announcements/:id" component={AnnouncementPage} />
|
<Route exact path="/announcements/:id" component={AnnouncementPage} />
|
||||||
<Route exact path="/account" component={AccountsPage} />
|
<Route exact path="/account" component={AccountsPage} />
|
||||||
<Route exact path="/settings" component={SettingPage} />
|
<Route exact path="/settings" component={SettingPage} />
|
||||||
<Route exact path="/admin" component={AdminPage} />
|
<Route exact path="/admin" component={AdminPage} />
|
||||||
</Switch>
|
</Switch>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -5,30 +5,30 @@ import { IAnnouncement } from '../../common/types/announcement';
|
||||||
import { $get } from '../misc/api';
|
import { $get } from '../misc/api';
|
||||||
|
|
||||||
export const AnnouncementList: React.VFC = () => {
|
export const AnnouncementList: React.VFC = () => {
|
||||||
const [announcements, setAnnouncements] = useState<IAnnouncement[]>([]);
|
const [announcements, setAnnouncements] = useState<IAnnouncement[]>([]);
|
||||||
|
|
||||||
const fetchAllAnnouncements = () => {
|
const fetchAllAnnouncements = () => {
|
||||||
setAnnouncements([]);
|
setAnnouncements([]);
|
||||||
$get<IAnnouncement[]>('announcements').then(announcements => {
|
$get<IAnnouncement[]>('announcements').then(announcements => {
|
||||||
setAnnouncements(announcements ?? []);
|
setAnnouncements(announcements ?? []);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchAllAnnouncements();
|
fetchAllAnnouncements();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (announcements.length === 0) return null;
|
if (announcements.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="large menu xmenu fade">
|
<div className="large menu xmenu fade">
|
||||||
{announcements.map(a => (
|
{announcements.map(a => (
|
||||||
<Link className="item fluid" key={a.id} to={`/announcements/${a.id}`}>
|
<Link className="item fluid" key={a.id} to={`/announcements/${a.id}`}>
|
||||||
{a.title}
|
{a.title}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -6,11 +6,11 @@ export type CardProps = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Card: React.FC<CardProps> = ({children, className, bodyClassName}) => {
|
export const Card: React.FC<CardProps> = ({children, className, bodyClassName}) => {
|
||||||
return (
|
return (
|
||||||
<div className={`card ${className}`}>
|
<div className={`card ${className}`}>
|
||||||
<div className={`body ${bodyClassName}`}>
|
<div className={`body ${bodyClassName}`}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -3,8 +3,8 @@ import { useGetSessionQuery } from '../services/session';
|
||||||
import { Skeleton } from './Skeleton';
|
import { Skeleton } from './Skeleton';
|
||||||
|
|
||||||
export const CurrentUser: React.VFC = () => {
|
export const CurrentUser: React.VFC = () => {
|
||||||
const {data} = useGetSessionQuery(undefined);
|
const {data} = useGetSessionQuery(undefined);
|
||||||
return data ? (
|
return data ? (
|
||||||
<h1 className="text-125"><i className="fas fa-users"></i> {data.username}<span className="text-dimmed">@{data.host}</span></h1>
|
<h1 className="text-125"><i className="fas fa-users"></i> {data.username}<span className="text-dimmed">@{data.host}</span></h1>
|
||||||
) : <Skeleton height="1.5rem" />;
|
) : <Skeleton height="1.5rem" />;
|
||||||
};
|
};
|
||||||
|
|
|
@ -6,12 +6,12 @@ export type HashtagTimelineProps = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const HashtagTimeline: React.VFC<HashtagTimelineProps> = ({hashtag}) => {
|
export const HashtagTimeline: React.VFC<HashtagTimelineProps> = ({hashtag}) => {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h1>{t('_timeline.title')}</h1>
|
<h1>{t('_timeline.title')}</h1>
|
||||||
<p>{t('_timeline.description', { hashtag })}</p>
|
<p>{t('_timeline.description', { hashtag })}</p>
|
||||||
<p>WIP</p>
|
<p>WIP</p>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,50 +1,50 @@
|
||||||
import React, {useMemo, useState} from 'react';
|
import React, {useMemo, useState} from 'react';
|
||||||
import {Log} from '../../common/types/log';
|
import {Log} from '../../common/types/log.js';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
const LogItem: React.FC<{log: Log}> = ({log}) => {
|
const LogItem: React.FC<{log: Log}> = ({log}) => {
|
||||||
const time = dayjs(log.timestamp).format('hh:mm:ss');
|
const time = dayjs(log.timestamp).format('hh:mm:ss');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`log ${log.level}`}>
|
<div className={`log ${log.level}`}>
|
||||||
[{time}] {log.text}
|
[{time}] {log.text}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LogView: React.FC<{log: Log[]}> = ({log}) => {
|
export const LogView: React.FC<{log: Log[]}> = ({log}) => {
|
||||||
const [isVisibleInfo, setVisibleInfo] = useState(true);
|
const [isVisibleInfo, setVisibleInfo] = useState(true);
|
||||||
const [isVisibleWarn, setVisibleWarn] = useState(true);
|
const [isVisibleWarn, setVisibleWarn] = useState(true);
|
||||||
const [isVisibleError, setVisibleError] = useState(true);
|
const [isVisibleError, setVisibleError] = useState(true);
|
||||||
|
|
||||||
const filter = useMemo(() => {
|
const filter = useMemo(() => {
|
||||||
const levels: Log['level'][] = [];
|
const levels: Log['level'][] = [];
|
||||||
if (isVisibleError) levels.push('error');
|
if (isVisibleError) levels.push('error');
|
||||||
if (isVisibleWarn) levels.push('warn');
|
if (isVisibleWarn) levels.push('warn');
|
||||||
if (isVisibleInfo) levels.push('info');
|
if (isVisibleInfo) levels.push('info');
|
||||||
|
|
||||||
return levels;
|
return levels;
|
||||||
}, [isVisibleError, isVisibleWarn, isVisibleInfo]);
|
}, [isVisibleError, isVisibleWarn, isVisibleInfo]);
|
||||||
|
|
||||||
const filteredLog = useMemo(() => log.filter(l => filter.includes(l.level)), [log, filter]);
|
const filteredLog = useMemo(() => log.filter(l => filter.includes(l.level)), [log, filter]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<label className="input-check">
|
<label className="input-check">
|
||||||
<input type="checkbox" checked={isVisibleInfo} onChange={e => setVisibleInfo(e.target.checked)} />
|
<input type="checkbox" checked={isVisibleInfo} onChange={e => setVisibleInfo(e.target.checked)} />
|
||||||
<span><i className="fas fa-circle-info fa-fw" /> INFO</span>
|
<span><i className="fas fa-circle-info fa-fw" /> INFO</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="input-check">
|
<label className="input-check">
|
||||||
<input type="checkbox" checked={isVisibleWarn} onChange={e => setVisibleWarn(e.target.checked)} />
|
<input type="checkbox" checked={isVisibleWarn} onChange={e => setVisibleWarn(e.target.checked)} />
|
||||||
<span><i className="fas fa-circle-exclamation fa-fw" /> WARN</span>
|
<span><i className="fas fa-circle-exclamation fa-fw" /> WARN</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="input-check">
|
<label className="input-check">
|
||||||
<input type="checkbox" checked={isVisibleError} onChange={e => setVisibleError(e.target.checked)} />
|
<input type="checkbox" checked={isVisibleError} onChange={e => setVisibleError(e.target.checked)} />
|
||||||
<span><i className="fas fa-circle-xmark fa-fw" /> ERROR</span>
|
<span><i className="fas fa-circle-xmark fa-fw" /> ERROR</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="log-view vstack slim">
|
<div className="log-view vstack slim">
|
||||||
{filteredLog.map(l => <LogItem log={l} key={l.text} />)}
|
{filteredLog.map(l => <LogItem log={l} key={`${l.level} ${l.timestamp.valueOf()} ${l.text}`} />)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -8,39 +8,39 @@ const Input = styled.input`
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const LoginForm: React.VFC = () => {
|
export const LoginForm: React.VFC = () => {
|
||||||
const [host, setHost] = useState('');
|
const [host, setHost] = useState('');
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
|
|
||||||
const login = () => {
|
const login = () => {
|
||||||
location.href = `//${location.host}/login?host=${encodeURIComponent(host)}`;
|
location.href = `//${location.host}/login?host=${encodeURIComponent(host)}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav>
|
<nav>
|
||||||
<div>
|
<div>
|
||||||
<strong>{t('instanceUrl')}</strong>
|
<strong>{t('instanceUrl')}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className="hgroup login-form">
|
<div className="hgroup login-form">
|
||||||
<Input
|
<Input
|
||||||
className="input-field"
|
className="input-field"
|
||||||
type="text"
|
type="text"
|
||||||
value={host}
|
value={host}
|
||||||
placeholder={t('instanceUrlPlaceholder')}
|
placeholder={t('instanceUrlPlaceholder')}
|
||||||
onChange={(e) => setHost(e.target.value)}
|
onChange={(e) => setHost(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') login();
|
if (e.key === 'Enter') login();
|
||||||
}}
|
}}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
className={!host ? 'btn' : 'btn primary'}
|
className={!host ? 'btn' : 'btn primary'}
|
||||||
style={{ width: 128 }}
|
style={{ width: 128 }}
|
||||||
disabled={!host}
|
disabled={!host}
|
||||||
onClick={login}
|
onClick={login}
|
||||||
>
|
>
|
||||||
{t('login')}
|
{t('login')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -9,65 +9,65 @@ import { setDrawerShown } from '../store/slices/screen';
|
||||||
const navLinkClassName = (isActive: boolean) => `item ${isActive ? 'active' : ''}`;
|
const navLinkClassName = (isActive: boolean) => `item ${isActive ? 'active' : ''}`;
|
||||||
|
|
||||||
export const NavigationMenu: React.VFC = () => {
|
export const NavigationMenu: React.VFC = () => {
|
||||||
const { data: session } = useGetSessionQuery(undefined);
|
const { data: session } = useGetSessionQuery(undefined);
|
||||||
const { data: meta } = useGetMetaQuery(undefined);
|
const { data: meta } = useGetMetaQuery(undefined);
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
const onClickItem = () => {
|
const onClickItem = () => {
|
||||||
dispatch(setDrawerShown(false));
|
dispatch(setDrawerShown(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h1 className="text-175 text-dimmed mb-2 font-misskey">{t('title')}</h1>
|
<h1 className="text-175 text-dimmed mb-2 font-misskey">{t('title')}</h1>
|
||||||
<div className="menu">
|
<div className="menu">
|
||||||
<section>
|
<section>
|
||||||
<NavLink className={navLinkClassName} to="/" exact onClick={onClickItem}>
|
<NavLink className={navLinkClassName} to="/" exact onClick={onClickItem}>
|
||||||
<i className={`icon fas fa-${session ? 'home' : 'arrow-left'}`}></i>
|
<i className={`icon fas fa-${session ? 'home' : 'arrow-left'}`}></i>
|
||||||
{t(session ? '_sidebar.dashboard' : '_sidebar.return')}
|
{t(session ? '_sidebar.dashboard' : '_sidebar.return')}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</section>
|
</section>
|
||||||
{session && (
|
{session && (
|
||||||
<section>
|
<section>
|
||||||
<h1>{t('_sidebar.tools')}</h1>
|
<h1>{t('_sidebar.tools')}</h1>
|
||||||
<NavLink className={navLinkClassName} to="/apps/miss-hai" onClick={onClickItem}>
|
<NavLink className={navLinkClassName} to="/apps/miss-hai" onClick={onClickItem}>
|
||||||
<i className="icon fas fa-tower-broadcast"></i>
|
<i className="icon fas fa-tower-broadcast"></i>
|
||||||
{t('_sidebar.missHaiAlert')}
|
{t('_sidebar.missHaiAlert')}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink className={navLinkClassName} to="/apps/avatar-cropper" onClick={onClickItem}>
|
<NavLink className={navLinkClassName} to="/apps/avatar-cropper" onClick={onClickItem}>
|
||||||
<i className="icon fas fa-crop-simple"></i>
|
<i className="icon fas fa-crop-simple"></i>
|
||||||
{t('_sidebar.cropper')}
|
{t('_sidebar.cropper')}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
{session && (
|
{session && (
|
||||||
<section>
|
<section>
|
||||||
<h1>{session.username}@{session.host}</h1>
|
<h1>{session.username}@{session.host}</h1>
|
||||||
<NavLink className={navLinkClassName} to="/account" onClick={onClickItem}>
|
<NavLink className={navLinkClassName} to="/account" onClick={onClickItem}>
|
||||||
<i className="icon fas fa-circle-user"></i>
|
<i className="icon fas fa-circle-user"></i>
|
||||||
{t('_sidebar.accounts')}
|
{t('_sidebar.accounts')}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink className={navLinkClassName} to="/settings" onClick={onClickItem}>
|
<NavLink className={navLinkClassName} to="/settings" onClick={onClickItem}>
|
||||||
<i className="icon fas fa-gear"></i>
|
<i className="icon fas fa-gear"></i>
|
||||||
{t('_sidebar.settings')}
|
{t('_sidebar.settings')}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
{session.isAdmin && (
|
{session.isAdmin && (
|
||||||
<NavLink className={navLinkClassName} to="/admin" onClick={onClickItem}>
|
<NavLink className={navLinkClassName} to="/admin" onClick={onClickItem}>
|
||||||
<i className="icon fas fa-lock"></i>
|
<i className="icon fas fa-lock"></i>
|
||||||
{t('_sidebar.admin')}
|
{t('_sidebar.admin')}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
{meta && (
|
{meta && (
|
||||||
<section>
|
<section>
|
||||||
<a className="item" href={CHANGELOG_URL} onClick={onClickItem}>
|
<a className="item" href={CHANGELOG_URL} onClick={onClickItem}>
|
||||||
v{meta.version} {t('changelog')}
|
v{meta.version} {t('changelog')}
|
||||||
</a>
|
</a>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -20,46 +20,46 @@ export type RankingProps = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Ranking: React.VFC<RankingProps> = ({limit}) => {
|
export const Ranking: React.VFC<RankingProps> = ({limit}) => {
|
||||||
const [response, setResponse] = useState<RankingResponse | null>(null);
|
const [response, setResponse] = useState<RankingResponse | null>(null);
|
||||||
const [isFetching, setIsFetching] = useState(true);
|
const [isFetching, setIsFetching] = useState(true);
|
||||||
const [isError, setIsError] = useState(false);
|
const [isError, setIsError] = useState(false);
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
|
|
||||||
// APIコール
|
// APIコール
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsFetching(true);
|
setIsFetching(true);
|
||||||
$get<RankingResponse>(`ranking?limit=${limit ?? ''}`)
|
$get<RankingResponse>(`ranking?limit=${limit ?? ''}`)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
setResponse(result);
|
setResponse(result);
|
||||||
setIsFetching(false);
|
setIsFetching(false);
|
||||||
})
|
})
|
||||||
.catch(c => {
|
.catch(c => {
|
||||||
console.error(c);
|
console.error(c);
|
||||||
setIsError(true);
|
setIsError(true);
|
||||||
});
|
});
|
||||||
}, [limit, setIsFetching, setIsError]);
|
}, [limit, setIsFetching, setIsError]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
isFetching ? (
|
isFetching ? (
|
||||||
<p className="text-dimmed">{t('fetching')}</p>
|
<p className="text-dimmed">{t('fetching')}</p>
|
||||||
) : isError ? (
|
) : isError ? (
|
||||||
<div className="alert bg-danger">{t('failedToFetch')}</div>
|
<div className="alert bg-danger">{t('failedToFetch')}</div>
|
||||||
) : response ? (
|
) : response ? (
|
||||||
response.isCalculating ? (
|
response.isCalculating ? (
|
||||||
<p>{t('isCalculating')}</p>
|
<p>{t('isCalculating')}</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="menu large">
|
<div className="menu large">
|
||||||
{response.ranking.map((r, i) => (
|
{response.ranking.map((r, i) => (
|
||||||
<a href={`https://${r.host}/@${r.username}`} target="_blank" rel="noopener noreferrer nofollow" className="item flex" key={i}>
|
<a href={`https://${r.host}/@${r.username}`} target="_blank" rel="noopener noreferrer nofollow" className="item flex" key={i}>
|
||||||
<div className="text-bold pr-2">{i + 1}</div>
|
<div className="text-bold pr-2">{i + 1}</div>
|
||||||
<div>
|
<div>
|
||||||
{r.username}@{r.host}<br/>
|
{r.username}@{r.host}<br/>
|
||||||
<span className="text-dimmed text-75">{t('_missHai.rating')}: {r.rating}</span>
|
<span className="text-dimmed text-75">{t('_missHai.rating')}: {r.rating}</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
) : null
|
) : null
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -6,7 +6,7 @@ export type SkeletonProps = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Skeleton: React.VFC<SkeletonProps> = (p) => {
|
export const Skeleton: React.VFC<SkeletonProps> = (p) => {
|
||||||
return (
|
return (
|
||||||
<div className="skeleton" style={{width: p.width, height: p.height}}></div>
|
<div className="skeleton" style={{width: p.width, height: p.height}}></div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -14,20 +14,20 @@ export type TabProps = {
|
||||||
|
|
||||||
// タブコンポーネント
|
// タブコンポーネント
|
||||||
export const Tab: React.VFC<TabProps> = (props) => {
|
export const Tab: React.VFC<TabProps> = (props) => {
|
||||||
return (
|
return (
|
||||||
<div className="tab">
|
<div className="tab">
|
||||||
{props.items.map((item) => {
|
{props.items.map((item) => {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={item.key}
|
key={item.key}
|
||||||
className={'item ' + (item.key === props.selected ? 'active' : '')}
|
className={'item ' + (item.key === props.selected ? 'active' : '')}
|
||||||
onClick={() => props.onSelect(item.key)}
|
onClick={() => props.onSelect(item.key)}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
{item.isNew && <sup className="text-primary text-bold" style={{marginLeft: 2}}>NEW!</sup>}
|
{item.isNew && <sup className="text-primary text-bold" style={{marginLeft: 2}}>NEW!</sup>}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -3,18 +3,18 @@ import { IAnnouncement } from '../../common/types/announcement';
|
||||||
import { $get } from '../misc/api';
|
import { $get } from '../misc/api';
|
||||||
|
|
||||||
export const useAnnouncements = () => {
|
export const useAnnouncements = () => {
|
||||||
const [announcements, setAnnouncements] = useState<IAnnouncement[]>([]);
|
const [announcements, setAnnouncements] = useState<IAnnouncement[]>([]);
|
||||||
|
|
||||||
const fetchAllAnnouncements = () => {
|
const fetchAllAnnouncements = () => {
|
||||||
setAnnouncements([]);
|
setAnnouncements([]);
|
||||||
$get<IAnnouncement[]>('announcements').then(announcements => {
|
$get<IAnnouncement[]>('announcements').then(announcements => {
|
||||||
setAnnouncements(announcements ?? []);
|
setAnnouncements(announcements ?? []);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchAllAnnouncements();
|
fetchAllAnnouncements();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return announcements;
|
return announcements;
|
||||||
};
|
};
|
||||||
|
|
|
@ -3,11 +3,11 @@ import { useDispatch } from 'react-redux';
|
||||||
import { setTitle } from '../store/slices/screen';
|
import { setTitle } from '../store/slices/screen';
|
||||||
|
|
||||||
export const useTitle = (title: string) => {
|
export const useTitle = (title: string) => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(setTitle(title));
|
dispatch(setTitle(title));
|
||||||
return () => {
|
return () => {
|
||||||
dispatch(setTitle(null));
|
dispatch(setTitle(null));
|
||||||
};
|
};
|
||||||
}, [title]);
|
}, [title]);
|
||||||
};
|
};
|
||||||
|
|
|
@ -18,17 +18,17 @@ dayjs.extend(relativeTime);
|
||||||
let lng = localStorage[LOCALSTORAGE_KEY_LANG];
|
let lng = localStorage[LOCALSTORAGE_KEY_LANG];
|
||||||
|
|
||||||
if (!lng || !Object.keys(languageName).includes(lng)) {
|
if (!lng || !Object.keys(languageName).includes(lng)) {
|
||||||
lng = localStorage[LOCALSTORAGE_KEY_LANG] = getBrowserLanguage();
|
lng = localStorage[LOCALSTORAGE_KEY_LANG] = getBrowserLanguage();
|
||||||
}
|
}
|
||||||
|
|
||||||
i18n
|
i18n
|
||||||
.use(initReactI18next)
|
.use(initReactI18next)
|
||||||
.init({
|
.init({
|
||||||
resources,
|
resources,
|
||||||
lng,
|
lng,
|
||||||
interpolation: {
|
interpolation: {
|
||||||
escapeValue: false // Reactは常にXSS対策をしてくれるので、i18next側では対応不要
|
escapeValue: false // Reactは常にXSS対策をしてくれるので、i18next側では対応不要
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ReactDOM.render(<App/>, document.getElementById('app'));
|
ReactDOM.render(<App/>, document.getElementById('app'));
|
||||||
|
|
|
@ -6,30 +6,30 @@ import jaCR from './ja-cr.json';
|
||||||
import deepmerge from 'deepmerge';
|
import deepmerge from 'deepmerge';
|
||||||
|
|
||||||
const merge = (baseData: Record<string, unknown>, newData: Record<string, unknown>) => {
|
const merge = (baseData: Record<string, unknown>, newData: Record<string, unknown>) => {
|
||||||
return deepmerge(baseData, newData, {
|
return deepmerge(baseData, newData, {
|
||||||
isMergeableObject: obj => typeof obj === 'object'
|
isMergeableObject: obj => typeof obj === 'object'
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const _enUS = merge(jaJP, enUS);
|
const _enUS = merge(jaJP, enUS);
|
||||||
|
|
||||||
export const resources = {
|
export const resources = {
|
||||||
'ja_JP': { translation: jaJP },
|
'ja_JP': { translation: jaJP },
|
||||||
'en_US': { translation: _enUS },
|
'en_US': { translation: _enUS },
|
||||||
'ko_KR': { translation: merge(_enUS, koKR) },
|
'ko_KR': { translation: merge(_enUS, koKR) },
|
||||||
'ja_CR': { translation: merge(jaJP, jaCR) },
|
'ja_CR': { translation: merge(jaJP, jaCR) },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const languageName = {
|
export const languageName = {
|
||||||
'ja_JP': '日本語',
|
'ja_JP': '日本語',
|
||||||
'en_US': 'English',
|
'en_US': 'English',
|
||||||
'ko_KR': '한국어',
|
'ko_KR': '한국어',
|
||||||
'ja_CR': '怪レい日本语',
|
'ja_CR': '怪レい日本语',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type LanguageCode = keyof typeof resources;
|
export type LanguageCode = keyof typeof resources;
|
||||||
|
|
||||||
export const getBrowserLanguage = () => {
|
export const getBrowserLanguage = () => {
|
||||||
const lang = navigator.language.replace('-', '_').toLowerCase();
|
const lang = navigator.language.replace('-', '_').toLowerCase();
|
||||||
return (Object.keys(resources) as LanguageCode[]).find(k => k.toLowerCase().startsWith(lang)) ?? 'en_US';
|
return (Object.keys(resources) as LanguageCode[]).find(k => k.toLowerCase().startsWith(lang)) ?? 'en_US';
|
||||||
};
|
};
|
||||||
|
|
|
@ -3,47 +3,47 @@ import { API_ENDPOINT, LOCALSTORAGE_KEY_TOKEN } from '../const';
|
||||||
export type ApiOptions = Record<string, any>;
|
export type ApiOptions = Record<string, any>;
|
||||||
|
|
||||||
const getHeaders = (token?: string) => {
|
const getHeaders = (token?: string) => {
|
||||||
const _token = token ?? localStorage.getItem(LOCALSTORAGE_KEY_TOKEN);
|
const _token = token ?? localStorage.getItem(LOCALSTORAGE_KEY_TOKEN);
|
||||||
const headers: HeadersInit = {
|
const headers: HeadersInit = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
};
|
};
|
||||||
if (_token) {
|
if (_token) {
|
||||||
headers['Authorization'] = `Bearer ${_token}`;
|
headers['Authorization'] = `Bearer ${_token}`;
|
||||||
}
|
}
|
||||||
return headers;
|
return headers;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getResponse = <T>(r: Response) => r.status === 204 ? null : r.json() as unknown as T;
|
const getResponse = <T>(r: Response) => r.status === 204 ? null : r.json() as unknown as T;
|
||||||
|
|
||||||
export const $get = <T = any>(endpoint: string, token?: string): Promise<T | null> => {
|
export const $get = <T = any>(endpoint: string, token?: string): Promise<T | null> => {
|
||||||
return fetch(API_ENDPOINT + endpoint, {
|
return fetch(API_ENDPOINT + endpoint, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: getHeaders(token),
|
headers: getHeaders(token),
|
||||||
}).then(r => getResponse<T>(r));
|
}).then(r => getResponse<T>(r));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export const $put = <T = any>(endpoint: string, opts: ApiOptions = {}, token?: string): Promise<T | null> => {
|
export const $put = <T = any>(endpoint: string, opts: ApiOptions = {}, token?: string): Promise<T | null> => {
|
||||||
return fetch(API_ENDPOINT + endpoint, {
|
return fetch(API_ENDPOINT + endpoint, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: getHeaders(token),
|
headers: getHeaders(token),
|
||||||
body: JSON.stringify(opts),
|
body: JSON.stringify(opts),
|
||||||
}).then(r => getResponse<T>(r));
|
}).then(r => getResponse<T>(r));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export const $post = <T = any>(endpoint: string, opts: ApiOptions = {}, token?: string): Promise<T | null> => {
|
export const $post = <T = any>(endpoint: string, opts: ApiOptions = {}, token?: string): Promise<T | null> => {
|
||||||
return fetch(API_ENDPOINT + endpoint, {
|
return fetch(API_ENDPOINT + endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: getHeaders(token),
|
headers: getHeaders(token),
|
||||||
body: JSON.stringify(opts),
|
body: JSON.stringify(opts),
|
||||||
}).then(r => getResponse<T>(r));
|
}).then(r => getResponse<T>(r));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const $delete = <T = any>(endpoint: string, opts: ApiOptions = {}, token?: string): Promise<T | null> => {
|
export const $delete = <T = any>(endpoint: string, opts: ApiOptions = {}, token?: string): Promise<T | null> => {
|
||||||
return fetch(API_ENDPOINT + endpoint, {
|
return fetch(API_ENDPOINT + endpoint, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: getHeaders(token),
|
headers: getHeaders(token),
|
||||||
body: JSON.stringify(opts),
|
body: JSON.stringify(opts),
|
||||||
}).then(r => getResponse<T>(r));
|
}).then(r => getResponse<T>(r));
|
||||||
};
|
};
|
||||||
|
|
|
@ -2,13 +2,13 @@ import { useCallback, useEffect, useState } from 'react';
|
||||||
import { useSelector } from '../store';
|
import { useSelector } from '../store';
|
||||||
|
|
||||||
export const actualThemes = [
|
export const actualThemes = [
|
||||||
'light',
|
'light',
|
||||||
'dark',
|
'dark',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const themes = [
|
export const themes = [
|
||||||
...actualThemes,
|
...actualThemes,
|
||||||
'system',
|
'system',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type Theme = typeof themes[number];
|
export type Theme = typeof themes[number];
|
||||||
|
@ -16,42 +16,42 @@ export type Theme = typeof themes[number];
|
||||||
export type ActualTheme = typeof actualThemes[number];
|
export type ActualTheme = typeof actualThemes[number];
|
||||||
|
|
||||||
export const useTheme = () => {
|
export const useTheme = () => {
|
||||||
const {theme, accentColor} = useSelector(state => state.screen);
|
const {theme, accentColor} = useSelector(state => state.screen);
|
||||||
|
|
||||||
const [ osTheme, setOsTheme ] = useState<ActualTheme>('dark');
|
const [ osTheme, setOsTheme ] = useState<ActualTheme>('dark');
|
||||||
|
|
||||||
const applyTheme = useCallback(() => {
|
const applyTheme = useCallback(() => {
|
||||||
const actualTheme = theme === 'system' ? osTheme : theme;
|
const actualTheme = theme === 'system' ? osTheme : theme;
|
||||||
if (actualTheme === 'dark') {
|
if (actualTheme === 'dark') {
|
||||||
document.body.classList.add('dark');
|
document.body.classList.add('dark');
|
||||||
} else {
|
} else {
|
||||||
document.body.classList.remove('dark');
|
document.body.classList.remove('dark');
|
||||||
}
|
}
|
||||||
}, [theme, osTheme]);
|
}, [theme, osTheme]);
|
||||||
|
|
||||||
// テーマ変更に追従する
|
// テーマ変更に追従する
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
applyTheme();
|
applyTheme();
|
||||||
}, [theme, osTheme]);
|
}, [theme, osTheme]);
|
||||||
|
|
||||||
// システムテーマ変更に追従する
|
// システムテーマ変更に追従する
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const q = window.matchMedia('(prefers-color-scheme: dark)');
|
const q = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
setOsTheme(q.matches ? 'dark' : 'light');
|
setOsTheme(q.matches ? 'dark' : 'light');
|
||||||
|
|
||||||
const listener = () => setOsTheme(q.matches ? 'dark' : 'light');
|
const listener = () => setOsTheme(q.matches ? 'dark' : 'light');
|
||||||
q.addEventListener('change', listener);
|
q.addEventListener('change', listener);
|
||||||
return () => {
|
return () => {
|
||||||
q.removeEventListener('change', listener);
|
q.removeEventListener('change', listener);
|
||||||
};
|
};
|
||||||
}, [osTheme, setOsTheme]);
|
}, [osTheme, setOsTheme]);
|
||||||
|
|
||||||
// カラー変更に追従する
|
// カラー変更に追従する
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const {style} = document.body;
|
const {style} = document.body;
|
||||||
style.setProperty('--primary', `var(--${accentColor})`);
|
style.setProperty('--primary', `var(--${accentColor})`);
|
||||||
for (let i = 1; i <= 10; i++) {
|
for (let i = 1; i <= 10; i++) {
|
||||||
style.setProperty(`--primary-${i}`, `var(--${accentColor}-${i})`);
|
style.setProperty(`--primary-${i}`, `var(--${accentColor}-${i})`);
|
||||||
}
|
}
|
||||||
}, [accentColor]);
|
}, [accentColor]);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
export interface ModalTypeDialog {
|
export interface ModalTypeDialog {
|
||||||
type: 'dialog';
|
type: 'dialog';
|
||||||
title?: string;
|
title?: string;
|
||||||
message: string;
|
message: string;
|
||||||
icon?: DialogIcon;
|
icon?: DialogIcon;
|
||||||
buttons?: DialogButtonType;
|
buttons?: DialogButtonType;
|
||||||
primaryClassName?: string;
|
primaryClassName?: string;
|
||||||
onSelect?: (clickedButtonIndex: number) => void;
|
onSelect?: (clickedButtonIndex: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DialogIcon = 'info' | 'warning' | 'error' | 'question';
|
export type DialogIcon = 'info' | 'warning' | 'error' | 'question';
|
||||||
|
@ -15,20 +15,20 @@ export type DialogButtonType = 'ok' | 'yesNo' | DialogButton[];
|
||||||
export type DialogButtonStyle = 'primary' | 'danger';
|
export type DialogButtonStyle = 'primary' | 'danger';
|
||||||
|
|
||||||
export interface DialogButton {
|
export interface DialogButton {
|
||||||
text: string;
|
text: string;
|
||||||
style?: DialogButtonStyle;
|
style?: DialogButtonStyle;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const builtinDialogButtonOk: DialogButton = {
|
export const builtinDialogButtonOk: DialogButton = {
|
||||||
text: 'OK',
|
text: 'OK',
|
||||||
style: 'primary',
|
style: 'primary',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const builtinDialogButtonYes: DialogButton = {
|
export const builtinDialogButtonYes: DialogButton = {
|
||||||
text: 'はい',
|
text: 'はい',
|
||||||
style: 'primary',
|
style: 'primary',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const builtinDialogButtonNo: DialogButton = {
|
export const builtinDialogButtonNo: DialogButton = {
|
||||||
text: 'いいえ',
|
text: 'いいえ',
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,16 +1,16 @@
|
||||||
export interface ModalTypeMenu {
|
export interface ModalTypeMenu {
|
||||||
type: 'menu';
|
type: 'menu';
|
||||||
screenX: number;
|
screenX: number;
|
||||||
screenY: number;
|
screenY: number;
|
||||||
items: MenuItem[];
|
items: MenuItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MenuItemClassName = `fas fa-${string}`;
|
export type MenuItemClassName = `fas fa-${string}`;
|
||||||
|
|
||||||
export interface MenuItem {
|
export interface MenuItem {
|
||||||
icon?: MenuItemClassName;
|
icon?: MenuItemClassName;
|
||||||
name: string;
|
name: string;
|
||||||
onClick: VoidFunction;
|
onClick: VoidFunction;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
danger?: boolean;
|
danger?: boolean;
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,6 @@ import { ModalTypeDialog } from './dialog';
|
||||||
import { ModalTypeMenu } from './menu';
|
import { ModalTypeMenu } from './menu';
|
||||||
|
|
||||||
export type Modal =
|
export type Modal =
|
||||||
| ModalTypeMenu
|
| ModalTypeMenu
|
||||||
| ModalTypeDialog;
|
| ModalTypeDialog;
|
||||||
|
|
||||||
|
|
|
@ -10,59 +10,59 @@ import { Skeleton } from '../components/Skeleton';
|
||||||
import { useTitle } from '../hooks/useTitle';
|
import { useTitle } from '../hooks/useTitle';
|
||||||
|
|
||||||
export const AccountsPage: React.VFC = () => {
|
export const AccountsPage: React.VFC = () => {
|
||||||
const {data} = useGetSessionQuery(undefined);
|
const {data} = useGetSessionQuery(undefined);
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
useTitle('_sidebar.accounts');
|
useTitle('_sidebar.accounts');
|
||||||
|
|
||||||
const {accounts, accountTokens} = useSelector(state => state.screen);
|
const {accounts, accountTokens} = useSelector(state => state.screen);
|
||||||
|
|
||||||
const switchAccount = (token: string) => {
|
const switchAccount = (token: string) => {
|
||||||
const newAccounts = accountTokens.filter(a => a !== token);
|
const newAccounts = accountTokens.filter(a => a !== token);
|
||||||
newAccounts.push(localStorage.getItem(LOCALSTORAGE_KEY_TOKEN) ?? '');
|
newAccounts.push(localStorage.getItem(LOCALSTORAGE_KEY_TOKEN) ?? '');
|
||||||
localStorage.setItem(LOCALSTORAGE_KEY_ACCOUNTS, JSON.stringify(newAccounts));
|
localStorage.setItem(LOCALSTORAGE_KEY_ACCOUNTS, JSON.stringify(newAccounts));
|
||||||
localStorage.setItem(LOCALSTORAGE_KEY_TOKEN, token);
|
localStorage.setItem(LOCALSTORAGE_KEY_TOKEN, token);
|
||||||
location.reload();
|
location.reload();
|
||||||
};
|
};
|
||||||
|
|
||||||
return !data ? (
|
return !data ? (
|
||||||
<div className="vstack">
|
<div className="vstack">
|
||||||
<Skeleton />
|
<Skeleton />
|
||||||
<Skeleton />
|
<Skeleton />
|
||||||
<Skeleton />
|
<Skeleton />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<article className="fade">
|
<article className="fade">
|
||||||
<section>
|
<section>
|
||||||
<h2>{t('_accounts.switchAccount')}</h2>
|
<h2>{t('_accounts.switchAccount')}</h2>
|
||||||
|
|
||||||
<div className="menu xmenu large fluid mb-2">
|
<div className="menu xmenu large fluid mb-2">
|
||||||
{
|
{
|
||||||
accounts.length === accountTokens.length ? (
|
accounts.length === accountTokens.length ? (
|
||||||
accounts.map(account => (
|
accounts.map(account => (
|
||||||
<button className="item fluid" style={{display: 'flex', flexDirection: 'row', alignItems: 'center'}} onClick={() => switchAccount(account.misshaiToken)}>
|
<button className="item fluid" style={{display: 'flex', flexDirection: 'row', alignItems: 'center'}} onClick={() => switchAccount(account.misshaiToken)}>
|
||||||
<i className="icon fas fa-chevron-right" />
|
<i className="icon fas fa-chevron-right" />
|
||||||
@{account.username}@{account.host}
|
@{account.username}@{account.host}
|
||||||
<button className="btn flat text-danger" style={{marginLeft: 'auto'}} onClick={e => {
|
<button className="btn flat text-danger" style={{marginLeft: 'auto'}} onClick={e => {
|
||||||
const filteredAccounts = accounts.filter(ac => ac.id !== account.id);
|
const filteredAccounts = accounts.filter(ac => ac.id !== account.id);
|
||||||
dispatch(setAccounts(filteredAccounts));
|
dispatch(setAccounts(filteredAccounts));
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}}>
|
}}>
|
||||||
<i className="fas fa-trash-can"/>
|
<i className="fas fa-trash-can"/>
|
||||||
</button>
|
</button>
|
||||||
</button>
|
</button>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="item">...</div>
|
<div className="item">...</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
<h2>{t('_accounts.useAnother')}</h2>
|
<h2>{t('_accounts.useAnother')}</h2>
|
||||||
<LoginForm />
|
<LoginForm />
|
||||||
</section>
|
</section>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -13,200 +13,200 @@ import {LogView} from '../components/LogView';
|
||||||
|
|
||||||
|
|
||||||
export const AdminPage: React.VFC = () => {
|
export const AdminPage: React.VFC = () => {
|
||||||
const { data, error } = useGetSessionQuery(undefined);
|
const { data, error } = useGetSessionQuery(undefined);
|
||||||
|
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
useTitle('_sidebar.admin');
|
useTitle('_sidebar.admin');
|
||||||
|
|
||||||
const [announcements, setAnnouncements] = useState<IAnnouncement[]>([]);
|
const [announcements, setAnnouncements] = useState<IAnnouncement[]>([]);
|
||||||
const [selectedAnnouncement, selectAnnouncement] = useState<IAnnouncement | null>(null);
|
const [selectedAnnouncement, selectAnnouncement] = useState<IAnnouncement | null>(null);
|
||||||
const [isAnnouncementsLoaded, setAnnouncementsLoaded] = useState(false);
|
const [isAnnouncementsLoaded, setAnnouncementsLoaded] = useState(false);
|
||||||
const [isEditMode, setEditMode] = useState(false);
|
const [isEditMode, setEditMode] = useState(false);
|
||||||
const [isDeleteMode, setDeleteMode] = useState(false);
|
const [isDeleteMode, setDeleteMode] = useState(false);
|
||||||
const [draftTitle, setDraftTitle] = useState('');
|
const [draftTitle, setDraftTitle] = useState('');
|
||||||
const [draftBody, setDraftBody] = useState('');
|
const [draftBody, setDraftBody] = useState('');
|
||||||
|
|
||||||
const [misshaiLog, setMisshaiLog] = useState<Log[] | null>(null);
|
const [misshaiLog, setMisshaiLog] = useState<Log[] | null>(null);
|
||||||
|
|
||||||
const submitAnnouncement = async () => {
|
const submitAnnouncement = async () => {
|
||||||
if (selectedAnnouncement) {
|
if (selectedAnnouncement) {
|
||||||
await $put('announcements', {
|
await $put('announcements', {
|
||||||
id: selectedAnnouncement.id,
|
id: selectedAnnouncement.id,
|
||||||
title: draftTitle,
|
title: draftTitle,
|
||||||
body: draftBody,
|
body: draftBody,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await $post('announcements', {
|
await $post('announcements', {
|
||||||
title: draftTitle,
|
title: draftTitle,
|
||||||
body: draftBody,
|
body: draftBody,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
selectAnnouncement(null);
|
selectAnnouncement(null);
|
||||||
setDraftTitle('');
|
setDraftTitle('');
|
||||||
setDraftBody('');
|
setDraftBody('');
|
||||||
setEditMode(false);
|
setEditMode(false);
|
||||||
fetchAll();
|
fetchAll();
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteAnnouncement = ({id}: IAnnouncement) => {
|
const deleteAnnouncement = ({id}: IAnnouncement) => {
|
||||||
$delete('announcements', {id}).then(() => {
|
$delete('announcements', {id}).then(() => {
|
||||||
fetchAll();
|
fetchAll();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchAll = () => {
|
const fetchAll = () => {
|
||||||
setAnnouncements([]);
|
setAnnouncements([]);
|
||||||
setAnnouncementsLoaded(false);
|
setAnnouncementsLoaded(false);
|
||||||
$get<IAnnouncement[]>('announcements').then(announcements => {
|
$get<IAnnouncement[]>('announcements').then(announcements => {
|
||||||
setAnnouncements(announcements ?? []);
|
setAnnouncements(announcements ?? []);
|
||||||
setAnnouncementsLoaded(true);
|
setAnnouncementsLoaded(true);
|
||||||
});
|
});
|
||||||
fetchLog();
|
fetchLog();
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchLog = () => {
|
const fetchLog = () => {
|
||||||
$get<Log[]>('admin/misshai/log').then(setMisshaiLog);
|
$get<Log[]>('admin/misshai/log').then(setMisshaiLog);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onClickStartMisshaiAlertWorkerButton = () => {
|
const onClickStartMisshaiAlertWorkerButton = () => {
|
||||||
$post('admin/misshai/start').then(() => {
|
$post('admin/misshai/start').then(() => {
|
||||||
dispatch(showModal({
|
dispatch(showModal({
|
||||||
type: 'dialog',
|
type: 'dialog',
|
||||||
message: '開始',
|
message: '開始',
|
||||||
}));
|
}));
|
||||||
}).catch((e) => {
|
}).catch((e) => {
|
||||||
dispatch(showModal({
|
dispatch(showModal({
|
||||||
type: 'dialog',
|
type: 'dialog',
|
||||||
icon: 'error',
|
icon: 'error',
|
||||||
message: e.message,
|
message: e.message,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Session APIのエラーハンドリング
|
* Session APIのエラーハンドリング
|
||||||
* このAPIがエラーを返した = トークンが無効 なのでトークンを削除してログアウトする
|
* このAPIがエラーを返した = トークンが無効 なのでトークンを削除してログアウトする
|
||||||
*/
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
localStorage.removeItem(LOCALSTORAGE_KEY_TOKEN);
|
localStorage.removeItem(LOCALSTORAGE_KEY_TOKEN);
|
||||||
location.reload();
|
location.reload();
|
||||||
}
|
}
|
||||||
}, [error]);
|
}, [error]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Edit Modeがオンのとき、Delete Modeを無効化する(誤操作防止)
|
* Edit Modeがオンのとき、Delete Modeを無効化する(誤操作防止)
|
||||||
*/
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isEditMode) {
|
if (isEditMode) {
|
||||||
setDeleteMode(false);
|
setDeleteMode(false);
|
||||||
}
|
}
|
||||||
}, [isEditMode]);
|
}, [isEditMode]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* お知らせ取得
|
* お知らせ取得
|
||||||
*/
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchAll();
|
fetchAll();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedAnnouncement) {
|
if (selectedAnnouncement) {
|
||||||
setDraftTitle(selectedAnnouncement.title);
|
setDraftTitle(selectedAnnouncement.title);
|
||||||
setDraftBody(selectedAnnouncement.body);
|
setDraftBody(selectedAnnouncement.body);
|
||||||
} else {
|
} else {
|
||||||
setDraftTitle('');
|
setDraftTitle('');
|
||||||
setDraftBody('');
|
setDraftBody('');
|
||||||
}
|
}
|
||||||
}, [selectedAnnouncement]);
|
}, [selectedAnnouncement]);
|
||||||
|
|
||||||
return !data || !isAnnouncementsLoaded ? (
|
return !data || !isAnnouncementsLoaded ? (
|
||||||
<div className="vstack">
|
<div className="vstack">
|
||||||
<Skeleton width="100%" height="1rem" />
|
<Skeleton width="100%" height="1rem" />
|
||||||
<Skeleton width="100%" height="1rem" />
|
<Skeleton width="100%" height="1rem" />
|
||||||
<Skeleton width="100%" height="2rem" />
|
<Skeleton width="100%" height="2rem" />
|
||||||
<Skeleton width="100%" height="160px" />
|
<Skeleton width="100%" height="160px" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="fade vstack">
|
<div className="fade vstack">
|
||||||
{
|
{
|
||||||
!data.isAdmin ? (
|
!data.isAdmin ? (
|
||||||
<p>You are not an administrator and cannot open this page.</p>
|
<p>You are not an administrator and cannot open this page.</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="card shadow-2">
|
<div className="card shadow-2">
|
||||||
<div className="body">
|
<div className="body">
|
||||||
<h1>Announcements</h1>
|
<h1>Announcements</h1>
|
||||||
{!isEditMode && (
|
{!isEditMode && (
|
||||||
<label className="input-switch mb-2">
|
<label className="input-switch mb-2">
|
||||||
<input type="checkbox" checked={isDeleteMode} onChange={e => setDeleteMode(e.target.checked)}/>
|
<input type="checkbox" checked={isDeleteMode} onChange={e => setDeleteMode(e.target.checked)}/>
|
||||||
<div className="switch"></div>
|
<div className="switch"></div>
|
||||||
<span>Delete Mode</span>
|
<span>Delete Mode</span>
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
{ !isEditMode ? (
|
{ !isEditMode ? (
|
||||||
<>
|
<>
|
||||||
{isDeleteMode && <div className="ml-2 text-danger">Click the item to delete.</div>}
|
{isDeleteMode && <div className="ml-2 text-danger">Click the item to delete.</div>}
|
||||||
<div className="large menu">
|
<div className="large menu">
|
||||||
{announcements.map(a => (
|
{announcements.map(a => (
|
||||||
<button className="item fluid" key={a.id} onClick={() => {
|
<button className="item fluid" key={a.id} onClick={() => {
|
||||||
if (isDeleteMode) {
|
if (isDeleteMode) {
|
||||||
deleteAnnouncement(a);
|
deleteAnnouncement(a);
|
||||||
} else {
|
} else {
|
||||||
selectAnnouncement(a);
|
selectAnnouncement(a);
|
||||||
setEditMode(true);
|
setEditMode(true);
|
||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
{isDeleteMode && <i className="icon bi fas fa-trash-can text-danger" />}
|
{isDeleteMode && <i className="icon bi fas fa-trash-can text-danger" />}
|
||||||
{a.title}
|
{a.title}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{!isDeleteMode && (
|
{!isDeleteMode && (
|
||||||
<button className="item fluid" onClick={() => setEditMode(true)}>
|
<button className="item fluid" onClick={() => setEditMode(true)}>
|
||||||
<i className="icon fas fa-plus" />
|
<i className="icon fas fa-plus" />
|
||||||
Create New
|
Create New
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="vstack">
|
<div className="vstack">
|
||||||
<label className="input-field">
|
<label className="input-field">
|
||||||
Title
|
Title
|
||||||
<input type="text" value={draftTitle} onChange={e => setDraftTitle(e.target.value)} />
|
<input type="text" value={draftTitle} onChange={e => setDraftTitle(e.target.value)} />
|
||||||
</label>
|
</label>
|
||||||
<label className="input-field">
|
<label className="input-field">
|
||||||
Body
|
Body
|
||||||
<textarea className="input-field" value={draftBody} rows={10} onChange={e => setDraftBody(e.target.value)}/>
|
<textarea className="input-field" value={draftBody} rows={10} onChange={e => setDraftBody(e.target.value)}/>
|
||||||
</label>
|
</label>
|
||||||
<div className="hstack" style={{justifyContent: 'flex-end'}}>
|
<div className="hstack" style={{justifyContent: 'flex-end'}}>
|
||||||
<button className="btn primary" onClick={submitAnnouncement} disabled={!draftTitle || !draftBody}>
|
<button className="btn primary" onClick={submitAnnouncement} disabled={!draftTitle || !draftBody}>
|
||||||
Submit
|
Submit
|
||||||
</button>
|
</button>
|
||||||
<button className="btn" onClick={() => {
|
<button className="btn" onClick={() => {
|
||||||
selectAnnouncement(null);
|
selectAnnouncement(null);
|
||||||
setEditMode(false);
|
setEditMode(false);
|
||||||
}}>
|
}}>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h2>Misshai</h2>
|
<h2>Misshai</h2>
|
||||||
<div className="vstack">
|
<div className="vstack">
|
||||||
<button className="btn danger" onClick={onClickStartMisshaiAlertWorkerButton}>
|
<button className="btn danger" onClick={onClickStartMisshaiAlertWorkerButton}>
|
||||||
ミス廃アラートワーカーを強制起動する
|
ミス廃アラートワーカーを強制起動する
|
||||||
</button>
|
</button>
|
||||||
<h3>直近のワーカーエラー</h3>
|
<h3>直近のワーカーエラー</h3>
|
||||||
{misshaiLog && <LogView log={misshaiLog} />}
|
{misshaiLog && <LogView log={misshaiLog} />}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -9,30 +9,30 @@ import { useSelector } from '../store';
|
||||||
import { useTitle } from '../hooks/useTitle';
|
import { useTitle } from '../hooks/useTitle';
|
||||||
|
|
||||||
export const AnnouncementPage: React.VFC = () => {
|
export const AnnouncementPage: React.VFC = () => {
|
||||||
const { id } = useParams<{id: string}>();
|
const { id } = useParams<{id: string}>();
|
||||||
if (!id) return null;
|
if (!id) return null;
|
||||||
|
|
||||||
const [announcement, setAnnouncement] = useState<IAnnouncement | null>();
|
const [announcement, setAnnouncement] = useState<IAnnouncement | null>();
|
||||||
|
|
||||||
const lang = useSelector(state => state.screen.language);
|
const lang = useSelector(state => state.screen.language);
|
||||||
|
|
||||||
useTitle('announcements');
|
useTitle('announcements');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
$get<IAnnouncement>('announcements/' + id).then(setAnnouncement);
|
$get<IAnnouncement>('announcements/' + id).then(setAnnouncement);
|
||||||
}, [setAnnouncement]);
|
}, [setAnnouncement]);
|
||||||
return !announcement ? <Skeleton width="100%" height="10rem" /> : (
|
return !announcement ? <Skeleton width="100%" height="10rem" /> : (
|
||||||
<article className="fade">
|
<article className="fade">
|
||||||
<h2>
|
<h2>
|
||||||
{announcement.title}
|
{announcement.title}
|
||||||
<aside className="inline ml-1 text-dimmed text-100">
|
<aside className="inline ml-1 text-dimmed text-100">
|
||||||
<i className="fas fa-clock" />
|
<i className="fas fa-clock" />
|
||||||
{dayjs(announcement.createdAt).locale(lang.split('_')[0]).fromNow()}
|
{dayjs(announcement.createdAt).locale(lang.split('_')[0]).fromNow()}
|
||||||
</aside>
|
</aside>
|
||||||
</h2>
|
</h2>
|
||||||
<section>
|
<section>
|
||||||
<ReactMarkdown>{announcement.body}</ReactMarkdown>
|
<ReactMarkdown>{announcement.body}</ReactMarkdown>
|
||||||
</section>
|
</section>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -2,10 +2,10 @@ import React from 'react';
|
||||||
import { useTitle } from '../../hooks/useTitle';
|
import { useTitle } from '../../hooks/useTitle';
|
||||||
|
|
||||||
export const AnnouncementsPage: React.VFC = () => {
|
export const AnnouncementsPage: React.VFC = () => {
|
||||||
useTitle('announcements');
|
useTitle('announcements');
|
||||||
return (
|
return (
|
||||||
<div className="fade">
|
<div className="fade">
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -10,151 +10,151 @@ import 'react-image-crop/dist/ReactCrop.css';
|
||||||
import { useTitle } from '../../hooks/useTitle';
|
import { useTitle } from '../../hooks/useTitle';
|
||||||
|
|
||||||
export const NekomimiPage: React.VFC = () => {
|
export const NekomimiPage: React.VFC = () => {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
const [blobUrl, setBlobUrl] = useState<string | null>(null);
|
const [blobUrl, setBlobUrl] = useState<string | null>(null);
|
||||||
const [fileName, setFileName] = useState<string | null>(null);
|
const [fileName, setFileName] = useState<string | null>(null);
|
||||||
const [percentage, setPercentage] = useState(0);
|
const [percentage, setPercentage] = useState(0);
|
||||||
const [isUploading, setUploading] = useState(false);
|
const [isUploading, setUploading] = useState(false);
|
||||||
const [image, setImage] = useState<HTMLImageElement | null>(null);
|
const [image, setImage] = useState<HTMLImageElement | null>(null);
|
||||||
const [crop, setCrop] = useState<Partial<Crop>>({unit: '%', width: 100, aspect: 1 / 1});
|
const [crop, setCrop] = useState<Partial<Crop>>({unit: '%', width: 100, aspect: 1 / 1});
|
||||||
const [completedCrop, setCompletedCrop] = useState<Crop>();
|
const [completedCrop, setCompletedCrop] = useState<Crop>();
|
||||||
|
|
||||||
useTitle('catAdjuster');
|
useTitle('catAdjuster');
|
||||||
|
|
||||||
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
|
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
const {data} = useGetSessionQuery(undefined);
|
const {data} = useGetSessionQuery(undefined);
|
||||||
|
|
||||||
const beginUpload = async () => {
|
const beginUpload = async () => {
|
||||||
|
|
||||||
if (!previewCanvasRef.current) return;
|
if (!previewCanvasRef.current) return;
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
|
|
||||||
const canvas = previewCanvasRef.current;
|
const canvas = previewCanvasRef.current;
|
||||||
const blob = await new Promise<Blob | null>(res => canvas.toBlob(res));
|
const blob = await new Promise<Blob | null>(res => canvas.toBlob(res));
|
||||||
if (!blob) return;
|
if (!blob) return;
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('i', data.token);
|
formData.append('i', data.token);
|
||||||
formData.append('force', 'true');
|
formData.append('force', 'true');
|
||||||
formData.append('file', blob);
|
formData.append('file', blob);
|
||||||
formData.append('name', `(Cropped) ${fileName ?? 'File'}`);
|
formData.append('name', `(Cropped) ${fileName ?? 'File'}`);
|
||||||
|
|
||||||
await new Promise<void>((res, rej) => {
|
await new Promise<void>((res, rej) => {
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
xhr.open('POST', `https://${data.host}/api/drive/files/create`, true);
|
xhr.open('POST', `https://${data.host}/api/drive/files/create`, true);
|
||||||
xhr.onload = (e) => {
|
xhr.onload = () => {
|
||||||
setPercentage(100);
|
setPercentage(100);
|
||||||
const {id: avatarId} = JSON.parse(xhr.responseText);
|
const {id: avatarId} = JSON.parse(xhr.responseText);
|
||||||
fetch(`https://${data.host}/api/i/update`, {
|
fetch(`https://${data.host}/api/i/update`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ i: data.token, avatarId }),
|
body: JSON.stringify({ i: data.token, avatarId }),
|
||||||
}).then(() => res()).catch(rej);
|
}).then(() => res()).catch(rej);
|
||||||
};
|
};
|
||||||
xhr.onerror = rej;
|
xhr.onerror = rej;
|
||||||
xhr.upload.onprogress = e => {
|
xhr.upload.onprogress = e => {
|
||||||
setPercentage(Math.floor(e.loaded / e.total * 100));
|
setPercentage(Math.floor(e.loaded / e.total * 100));
|
||||||
};
|
};
|
||||||
xhr.send(formData);
|
xhr.send(formData);
|
||||||
});
|
});
|
||||||
|
|
||||||
dispatch(showModal({
|
dispatch(showModal({
|
||||||
type: 'dialog',
|
type: 'dialog',
|
||||||
icon: 'info',
|
icon: 'info',
|
||||||
message: t('saved'),
|
message: t('saved'),
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const onChangeFile: ChangeEventHandler<HTMLInputElement> = (e) => {
|
const onChangeFile: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||||
if (e.target.files === null || e.target.files.length === 0) return;
|
if (e.target.files === null || e.target.files.length === 0) return;
|
||||||
const file = e.target.files[0];
|
const file = e.target.files[0];
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
setFileName(file.name);
|
setFileName(file.name);
|
||||||
reader.addEventListener('load', () => setBlobUrl(reader.result as string));
|
reader.addEventListener('load', () => setBlobUrl(reader.result as string));
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
setCrop({unit: '%', width: 100, aspect: 1 / 1});
|
setCrop({unit: '%', width: 100, aspect: 1 / 1});
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!completedCrop || !previewCanvasRef.current || !image) {
|
if (!completedCrop || !previewCanvasRef.current || !image) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const canvas = previewCanvasRef.current;
|
const canvas = previewCanvasRef.current;
|
||||||
const crop = completedCrop;
|
const crop = completedCrop;
|
||||||
|
|
||||||
const scaleX = image.naturalWidth / image.width;
|
const scaleX = image.naturalWidth / image.width;
|
||||||
const scaleY = image.naturalHeight / image.height;
|
const scaleY = image.naturalHeight / image.height;
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
const pixelRatio = window.devicePixelRatio;
|
const pixelRatio = window.devicePixelRatio;
|
||||||
|
|
||||||
canvas.width = crop.width * pixelRatio * scaleX;
|
canvas.width = crop.width * pixelRatio * scaleX;
|
||||||
canvas.height = crop.height * pixelRatio * scaleY;
|
canvas.height = crop.height * pixelRatio * scaleY;
|
||||||
|
|
||||||
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
||||||
ctx.imageSmoothingQuality = 'high';
|
ctx.imageSmoothingQuality = 'high';
|
||||||
|
|
||||||
ctx.drawImage(
|
ctx.drawImage(
|
||||||
image,
|
image,
|
||||||
crop.x * scaleX,
|
crop.x * scaleX,
|
||||||
crop.y * scaleY,
|
crop.y * scaleY,
|
||||||
crop.width * scaleX,
|
crop.width * scaleX,
|
||||||
crop.height * scaleY,
|
crop.height * scaleY,
|
||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
crop.width * scaleX,
|
crop.width * scaleX,
|
||||||
crop.height * scaleY
|
crop.height * scaleY
|
||||||
);
|
);
|
||||||
}, [completedCrop]);
|
}, [completedCrop]);
|
||||||
|
|
||||||
const onClickUpload = async () => {
|
const onClickUpload = async () => {
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
setPercentage(0);
|
setPercentage(0);
|
||||||
try {
|
try {
|
||||||
await beginUpload();
|
await beginUpload();
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fade">
|
<div className="fade">
|
||||||
<h2>{t('catAdjuster')}</h2>
|
<h2>{t('catAdjuster')}</h2>
|
||||||
<input type="file" className="input-field" accept="image/*" onChange={onChangeFile} />
|
<input type="file" className="input-field" accept="image/*" onChange={onChangeFile} />
|
||||||
{blobUrl && (
|
{blobUrl && (
|
||||||
<div className="row mt-2">
|
<div className="row mt-2">
|
||||||
<div className="col-8 col-12-sm">
|
<div className="col-8 col-12-sm">
|
||||||
<ReactCrop src={blobUrl} crop={crop}
|
<ReactCrop src={blobUrl} crop={crop}
|
||||||
onImageLoaded={(i) => setImage(i)}
|
onImageLoaded={(i) => setImage(i)}
|
||||||
onChange={(c) => setCrop(c)}
|
onChange={(c) => setCrop(c)}
|
||||||
onComplete={(c) => setCompletedCrop(c)}
|
onComplete={(c) => setCompletedCrop(c)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-4 col-12-sm">
|
<div className="col-4 col-12-sm">
|
||||||
<h3 className="text-100 text-bold">{t('preview')}</h3>
|
<h3 className="text-100 text-bold">{t('preview')}</h3>
|
||||||
<div className="cat mt-4 mb-2" style={{position: 'relative', width: 96, height: 96}}>
|
<div className="cat mt-4 mb-2" style={{position: 'relative', width: 96, height: 96}}>
|
||||||
<canvas
|
<canvas
|
||||||
ref={previewCanvasRef}
|
ref={previewCanvasRef}
|
||||||
className="circle"
|
className="circle"
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
inset: 0,
|
inset: 0,
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
zIndex: 100,
|
zIndex: 100,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button className="btn primary" onClick={onClickUpload} disabled={isUploading}>
|
<button className="btn primary" onClick={onClickUpload} disabled={isUploading}>
|
||||||
{isUploading ? `${percentage}%` : t('upload')}
|
{isUploading ? `${percentage}%` : t('upload')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -17,17 +17,17 @@ import { useTitle } from '../../hooks/useTitle';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
const variables = [
|
const variables = [
|
||||||
'notesCount',
|
'notesCount',
|
||||||
'followingCount',
|
'followingCount',
|
||||||
'followersCount',
|
'followersCount',
|
||||||
'notesDelta',
|
'notesDelta',
|
||||||
'followingDelta',
|
'followingDelta',
|
||||||
'followersDelta',
|
'followersDelta',
|
||||||
'url',
|
'url',
|
||||||
'username',
|
'username',
|
||||||
'host',
|
'host',
|
||||||
'rating',
|
'rating',
|
||||||
'gacha',
|
'gacha',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
type SettingDraftType = Partial<Pick<IUser,
|
type SettingDraftType = Partial<Pick<IUser,
|
||||||
|
@ -42,289 +42,289 @@ type SettingDraftType = Partial<Pick<IUser,
|
||||||
type DraftReducer = React.Reducer<SettingDraftType, Partial<SettingDraftType>>;
|
type DraftReducer = React.Reducer<SettingDraftType, Partial<SettingDraftType>>;
|
||||||
|
|
||||||
export const MisshaiPage: React.VFC = () => {
|
export const MisshaiPage: React.VFC = () => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const session = useGetSessionQuery(undefined);
|
const session = useGetSessionQuery(undefined);
|
||||||
const data = session.data;
|
const data = session.data;
|
||||||
const score = useGetScoreQuery(undefined);
|
const score = useGetScoreQuery(undefined);
|
||||||
|
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
|
|
||||||
useTitle('_sidebar.missHaiAlert');
|
useTitle('_sidebar.missHaiAlert');
|
||||||
|
|
||||||
const [draft, dispatchDraft] = useReducer<DraftReducer>((state, action) => {
|
const [draft, dispatchDraft] = useReducer<DraftReducer>((state, action) => {
|
||||||
return { ...state, ...action };
|
return { ...state, ...action };
|
||||||
}, {
|
}, {
|
||||||
alertMode: data?.alertMode ?? 'note',
|
alertMode: data?.alertMode ?? 'note',
|
||||||
visibility: data?.visibility ?? 'public',
|
visibility: data?.visibility ?? 'public',
|
||||||
localOnly: data?.localOnly ?? false,
|
localOnly: data?.localOnly ?? false,
|
||||||
remoteFollowersOnly: data?.remoteFollowersOnly ?? false,
|
remoteFollowersOnly: data?.remoteFollowersOnly ?? false,
|
||||||
template: data?.template ?? null,
|
template: data?.template ?? null,
|
||||||
useRanking: data?.useRanking ?? false,
|
useRanking: data?.useRanking ?? false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const templateTextarea = useRef<HTMLTextAreaElement>(null);
|
const templateTextarea = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
const availableVisibilities: Visibility[] = [
|
const availableVisibilities: Visibility[] = [
|
||||||
'public',
|
'public',
|
||||||
'home',
|
'home',
|
||||||
'followers'
|
'followers'
|
||||||
];
|
];
|
||||||
|
|
||||||
const updateSetting = useCallback((obj: SettingDraftType) => {
|
const updateSetting = useCallback((obj: SettingDraftType) => {
|
||||||
const previousDraft = draft;
|
const previousDraft = draft;
|
||||||
dispatchDraft(obj);
|
dispatchDraft(obj);
|
||||||
return $put('session', obj)
|
return $put('session', obj)
|
||||||
.catch(e => {
|
.catch(() => {
|
||||||
dispatch(showModal({
|
dispatch(showModal({
|
||||||
type: 'dialog',
|
type: 'dialog',
|
||||||
icon: 'error',
|
icon: 'error',
|
||||||
message: t('error'),
|
message: t('error'),
|
||||||
}));
|
}));
|
||||||
dispatchDraft(previousDraft);
|
dispatchDraft(previousDraft);
|
||||||
});
|
});
|
||||||
}, [draft]);
|
}, [draft]);
|
||||||
|
|
||||||
const updateSettingWithDialog = useCallback((obj: SettingDraftType) => {
|
const updateSettingWithDialog = useCallback((obj: SettingDraftType) => {
|
||||||
updateSetting(obj)
|
updateSetting(obj)
|
||||||
.then(() => dispatch(showModal({
|
.then(() => dispatch(showModal({
|
||||||
type: 'dialog',
|
type: 'dialog',
|
||||||
icon: 'info',
|
icon: 'info',
|
||||||
message: t('saved'),
|
message: t('saved'),
|
||||||
})));
|
})));
|
||||||
}, [updateSetting]);
|
}, [updateSetting]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
dispatchDraft({
|
dispatchDraft({
|
||||||
alertMode: data.alertMode,
|
alertMode: data.alertMode,
|
||||||
visibility: data.visibility,
|
visibility: data.visibility,
|
||||||
localOnly: data.localOnly,
|
localOnly: data.localOnly,
|
||||||
remoteFollowersOnly: data.remoteFollowersOnly,
|
remoteFollowersOnly: data.remoteFollowersOnly,
|
||||||
template: data.template,
|
template: data.template,
|
||||||
useRanking: data.useRanking
|
useRanking: data.useRanking
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
const onClickInsertVariables = useCallback<React.MouseEventHandler>((e) => {
|
const onClickInsertVariables = useCallback<React.MouseEventHandler>((e) => {
|
||||||
dispatch(showModal({
|
dispatch(showModal({
|
||||||
type: 'menu',
|
type: 'menu',
|
||||||
screenX: e.clientX,
|
screenX: e.clientX,
|
||||||
screenY: e.clientY,
|
screenY: e.clientY,
|
||||||
items: variables.map(key => ({
|
items: variables.map(key => ({
|
||||||
name: t('_template._variables.' + key),
|
name: t('_template._variables.' + key),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
if (templateTextarea.current) {
|
if (templateTextarea.current) {
|
||||||
insertTextAtCursor(templateTextarea.current, `{${key}}`);
|
insertTextAtCursor(templateTextarea.current, `{${key}}`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
}, [dispatch, t, variables, templateTextarea.current]);
|
}, [dispatch, t, variables, templateTextarea.current]);
|
||||||
|
|
||||||
const onClickInsertVariablesHelp = useCallback(() => {
|
const onClickInsertVariablesHelp = useCallback(() => {
|
||||||
dispatch(showModal({
|
dispatch(showModal({
|
||||||
type: 'dialog',
|
type: 'dialog',
|
||||||
icon: 'info',
|
icon: 'info',
|
||||||
message: t('_template.insertVariablesHelp'),
|
message: t('_template.insertVariablesHelp'),
|
||||||
}));
|
}));
|
||||||
}, [dispatch, t]);
|
}, [dispatch, t]);
|
||||||
|
|
||||||
const onClickSendAlert = useCallback(() => {
|
const onClickSendAlert = useCallback(() => {
|
||||||
dispatch(showModal({
|
dispatch(showModal({
|
||||||
type: 'dialog',
|
type: 'dialog',
|
||||||
title: t('_sendTest.title'),
|
title: t('_sendTest.title'),
|
||||||
message: t('_sendTest.message'),
|
message: t('_sendTest.message'),
|
||||||
icon: 'question',
|
icon: 'question',
|
||||||
buttons: [
|
buttons: [
|
||||||
{
|
{
|
||||||
text: t('_sendTest.yes'),
|
text: t('_sendTest.yes'),
|
||||||
style: 'primary',
|
style: 'primary',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: t('_sendTest.no'),
|
text: t('_sendTest.no'),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
onSelect(i) {
|
onSelect(i) {
|
||||||
if (i === 0) {
|
if (i === 0) {
|
||||||
$post('session/alert').then(() => {
|
$post('session/alert').then(() => {
|
||||||
dispatch(showModal({
|
dispatch(showModal({
|
||||||
type: 'dialog',
|
type: 'dialog',
|
||||||
message: t('_sendTest.success'),
|
message: t('_sendTest.success'),
|
||||||
icon: 'info',
|
icon: 'info',
|
||||||
}));
|
}));
|
||||||
}).catch((e) => {
|
}).catch((e) => {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
dispatch(showModal({
|
dispatch(showModal({
|
||||||
type: 'dialog',
|
type: 'dialog',
|
||||||
message: t('_sendTest.failure'),
|
message: t('_sendTest.failure'),
|
||||||
icon: 'error',
|
icon: 'error',
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
}, [dispatch, t]);
|
}, [dispatch, t]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Session APIのエラーハンドリング
|
* Session APIのエラーハンドリング
|
||||||
* このAPIがエラーを返した = トークンが無効 なのでトークンを削除してログアウトする
|
* このAPIがエラーを返した = トークンが無効 なのでトークンを削除してログアウトする
|
||||||
*/
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (session.error) {
|
if (session.error) {
|
||||||
console.error(session.error);
|
console.error(session.error);
|
||||||
localStorage.removeItem(LOCALSTORAGE_KEY_TOKEN);
|
localStorage.removeItem(LOCALSTORAGE_KEY_TOKEN);
|
||||||
const a = localStorage.getItem(LOCALSTORAGE_KEY_ACCOUNTS);
|
const a = localStorage.getItem(LOCALSTORAGE_KEY_ACCOUNTS);
|
||||||
if (a) {
|
if (a) {
|
||||||
const accounts = JSON.parse(a) as string[];
|
const accounts = JSON.parse(a) as string[];
|
||||||
if (accounts.length > 0) {
|
if (accounts.length > 0) {
|
||||||
localStorage.setItem(LOCALSTORAGE_KEY_TOKEN, accounts[0]);
|
localStorage.setItem(LOCALSTORAGE_KEY_TOKEN, accounts[0]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
location.reload();
|
location.reload();
|
||||||
}
|
}
|
||||||
}, [session.error]);
|
}, [session.error]);
|
||||||
|
|
||||||
const defaultTemplate = t('_template.default');
|
const defaultTemplate = t('_template.default');
|
||||||
|
|
||||||
const remaining = 1024 - (draft.template ?? defaultTemplate).length;
|
const remaining = 1024 - (draft.template ?? defaultTemplate).length;
|
||||||
|
|
||||||
return session.isLoading || score.isLoading || !session.data || !score.data ? (
|
return session.isLoading || score.isLoading || !session.data || !score.data ? (
|
||||||
<div className="vstack">
|
<div className="vstack">
|
||||||
<Skeleton width="100%" height="1rem" />
|
<Skeleton width="100%" height="1rem" />
|
||||||
<Skeleton width="100%" height="1rem" />
|
<Skeleton width="100%" height="1rem" />
|
||||||
<Skeleton width="100%" height="2rem" />
|
<Skeleton width="100%" height="2rem" />
|
||||||
<Skeleton width="100%" height="160px" />
|
<Skeleton width="100%" height="160px" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<article className="fade">
|
<article className="fade">
|
||||||
<section className="misshaiData">
|
<section className="misshaiData">
|
||||||
<h2><i className="fas fa-chart-line"></i> {t('_missHai.data')}</h2>
|
<h2><i className="fas fa-chart-line"></i> {t('_missHai.data')}</h2>
|
||||||
<table className="table fluid">
|
<table className="table fluid">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th></th>
|
<th></th>
|
||||||
<th>{t('_missHai.dataScore')}</th>
|
<th>{t('_missHai.dataScore')}</th>
|
||||||
<th>{t('_missHai.dataDelta')}</th>
|
<th>{t('_missHai.dataDelta')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td>{t('notes')}</td>
|
<td>{t('notes')}</td>
|
||||||
<td>{score.data.notesCount}</td>
|
<td>{score.data.notesCount}</td>
|
||||||
<td>{score.data.notesDelta}</td>
|
<td>{score.data.notesDelta}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>{t('following')}</td>
|
<td>{t('following')}</td>
|
||||||
<td>{score.data.followingCount}</td>
|
<td>{score.data.followingCount}</td>
|
||||||
<td>{score.data.followingDelta}</td>
|
<td>{score.data.followingDelta}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>{t('followers')}</td>
|
<td>{t('followers')}</td>
|
||||||
<td>{score.data.followersCount}</td>
|
<td>{score.data.followersCount}</td>
|
||||||
<td>{score.data.followersDelta}</td>
|
<td>{score.data.followersDelta}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<p><strong>{t('_missHai.rating')}{': '}</strong>{session.data.rating}</p>
|
<p><strong>{t('_missHai.rating')}{': '}</strong>{session.data.rating}</p>
|
||||||
</section>
|
</section>
|
||||||
<section className="misshaiRanking">
|
<section className="misshaiRanking">
|
||||||
<h2><i className="fas fa-ranking-star"/> {t('_missHai.ranking')}</h2>
|
<h2><i className="fas fa-ranking-star"/> {t('_missHai.ranking')}</h2>
|
||||||
<Ranking limit={10} />
|
<Ranking limit={10} />
|
||||||
<Link to="/apps/miss-hai/ranking" className="block mt-2">{t('_missHai.showAll')}</Link>
|
<Link to="/apps/miss-hai/ranking" className="block mt-2">{t('_missHai.showAll')}</Link>
|
||||||
</section>
|
</section>
|
||||||
<section className="alertModeSetting">
|
<section className="alertModeSetting">
|
||||||
<h2><i className="fas fa-gear"></i> {t('settings')}</h2>
|
<h2><i className="fas fa-gear"></i> {t('settings')}</h2>
|
||||||
<div className="vstack">
|
<div className="vstack">
|
||||||
<div className="card pa-2">
|
<div className="card pa-2">
|
||||||
<label className="input-check">
|
<label className="input-check">
|
||||||
<input type="checkbox" checked={draft.useRanking} onChange={(e) => {
|
<input type="checkbox" checked={draft.useRanking} onChange={(e) => {
|
||||||
updateSetting({ useRanking: e.target.checked });
|
updateSetting({ useRanking: e.target.checked });
|
||||||
}}/>
|
}}/>
|
||||||
<span>{t('_missHai.useRanking')}</span>
|
<span>{t('_missHai.useRanking')}</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="card pa-2">
|
<div className="card pa-2">
|
||||||
<h3>{t('alertMode')}</h3>
|
<h3>{t('alertMode')}</h3>
|
||||||
<div className="vstack slim">
|
<div className="vstack slim">
|
||||||
{ alertModes.map((mode) => (
|
{ alertModes.map((mode) => (
|
||||||
<label key={mode} className="input-check">
|
<label key={mode} className="input-check">
|
||||||
<input type="radio" checked={mode === draft.alertMode} onChange={() => {
|
<input type="radio" checked={mode === draft.alertMode} onChange={() => {
|
||||||
updateSetting({ alertMode: mode });
|
updateSetting({ alertMode: mode });
|
||||||
}} />
|
}} />
|
||||||
<span>{t(`_alertMode.${mode}`)}</span>
|
<span>{t(`_alertMode.${mode}`)}</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{ (draft.alertMode === 'notification' || draft.alertMode === 'both') && (
|
{ (draft.alertMode === 'notification' || draft.alertMode === 'both') && (
|
||||||
<div className="alert bg-danger mt-2">
|
<div className="alert bg-danger mt-2">
|
||||||
<i className="icon fas fa-circle-exclamation"></i>
|
<i className="icon fas fa-circle-exclamation"></i>
|
||||||
{t('_alertMode.notificationWarning')}
|
{t('_alertMode.notificationWarning')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{ (draft.alertMode === 'note' || draft.alertMode === 'both') && (
|
{ (draft.alertMode === 'note' || draft.alertMode === 'both') && (
|
||||||
<>
|
<>
|
||||||
<h3 className="mt-2">{t('visibility')}</h3>
|
<h3 className="mt-2">{t('visibility')}</h3>
|
||||||
<div className="vstack slim">
|
<div className="vstack slim">
|
||||||
{
|
{
|
||||||
availableVisibilities.map((visibility) => (
|
availableVisibilities.map((visibility) => (
|
||||||
<label key={visibility} className="input-check">
|
<label key={visibility} className="input-check">
|
||||||
<input type="radio" checked={visibility === draft.visibility} onChange={() => {
|
<input type="radio" checked={visibility === draft.visibility} onChange={() => {
|
||||||
updateSetting({ visibility });
|
updateSetting({ visibility });
|
||||||
}} />
|
}} />
|
||||||
<span>{t(`_visibility.${visibility}`)}</span>
|
<span>{t(`_visibility.${visibility}`)}</span>
|
||||||
</label>
|
</label>
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<label className="input-check mt-2">
|
<label className="input-check mt-2">
|
||||||
<input type="checkbox" checked={draft.localOnly} onChange={(e) => {
|
<input type="checkbox" checked={draft.localOnly} onChange={(e) => {
|
||||||
updateSetting({ localOnly: e.target.checked });
|
updateSetting({ localOnly: e.target.checked });
|
||||||
}} />
|
}} />
|
||||||
<span>{t('localOnly')}</span>
|
<span>{t('localOnly')}</span>
|
||||||
</label>
|
</label>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="card pa-2">
|
<div className="card pa-2">
|
||||||
<h3>{t('template')}</h3>
|
<h3>{t('template')}</h3>
|
||||||
<p>{t('_template.description')}</p>
|
<p>{t('_template.description')}</p>
|
||||||
<div className="hstack dense mb-2">
|
<div className="hstack dense mb-2">
|
||||||
<button className="btn" onClick={onClickInsertVariables}>
|
<button className="btn" onClick={onClickInsertVariables}>
|
||||||
{'{ } '}
|
{'{ } '}
|
||||||
{t('_template.insertVariables')}
|
{t('_template.insertVariables')}
|
||||||
</button>
|
</button>
|
||||||
<button className="btn link text-info" onClick={onClickInsertVariablesHelp}>
|
<button className="btn link text-info" onClick={onClickInsertVariablesHelp}>
|
||||||
<i className="fas fa-circle-question" />
|
<i className="fas fa-circle-question" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="textarea-wrapper">
|
<div className="textarea-wrapper">
|
||||||
<textarea ref={templateTextarea} className="input-field" value={draft.template ?? defaultTemplate} placeholder={defaultTemplate} style={{height: 240}} onChange={(e) => {
|
<textarea ref={templateTextarea} className="input-field" value={draft.template ?? defaultTemplate} placeholder={defaultTemplate} style={{height: 240}} onChange={(e) => {
|
||||||
dispatchDraft({ template: e.target.value });
|
dispatchDraft({ template: e.target.value });
|
||||||
}} />
|
}} />
|
||||||
<span className={`textarea-remaining ${remaining <= 0 ? 'text-red text-bold' : ''}`}>{remaining}</span>
|
<span className={`textarea-remaining ${remaining <= 0 ? 'text-red text-bold' : ''}`}>{remaining}</span>
|
||||||
</div>
|
</div>
|
||||||
<small className="text-dimmed">{t('_template.description2')}</small>
|
<small className="text-dimmed">{t('_template.description2')}</small>
|
||||||
<div className="hstack mt-2">
|
<div className="hstack mt-2">
|
||||||
<button className="btn danger" onClick={() => dispatchDraft({ template: null })}>{t('resetToDefault')}</button>
|
<button className="btn danger" onClick={() => dispatchDraft({ template: null })}>{t('resetToDefault')}</button>
|
||||||
<button className="btn primary" onClick={() => {
|
<button className="btn primary" onClick={() => {
|
||||||
updateSettingWithDialog({ template: draft.template === '' ? null : draft.template });
|
updateSettingWithDialog({ template: draft.template === '' ? null : draft.template });
|
||||||
}} disabled={remaining < 0}>{t('save')}</button>
|
}} disabled={remaining < 0}>{t('save')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section className="list-form mt-2">
|
<section className="list-form mt-2">
|
||||||
<button className="item" onClick={onClickSendAlert} disabled={draft.alertMode === 'nothing'}>
|
<button className="item" onClick={onClickSendAlert} disabled={draft.alertMode === 'nothing'}>
|
||||||
<i className="icon fas fa-paper-plane" />
|
<i className="icon fas fa-paper-plane" />
|
||||||
<div className="body">
|
<div className="body">
|
||||||
<h1>{t('sendAlert')}</h1>
|
<h1>{t('sendAlert')}</h1>
|
||||||
<p className="desc">{t(draft.alertMode === 'nothing' ? 'sendAlertDisabled' : 'sendAlertDescription')}</p>
|
<p className="desc">{t(draft.alertMode === 'nothing' ? 'sendAlertDisabled' : 'sendAlertDescription')}</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -5,18 +5,18 @@ import { useTitle } from '../../../hooks/useTitle';
|
||||||
|
|
||||||
|
|
||||||
export const RankingPage: React.VFC = () => {
|
export const RankingPage: React.VFC = () => {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
useTitle('_missHai.ranking');
|
useTitle('_missHai.ranking');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article>
|
<article>
|
||||||
<h2>{t('_missHai.ranking')}</h2>
|
<h2>{t('_missHai.ranking')}</h2>
|
||||||
<section>
|
<section>
|
||||||
<p>{t('_missHai.rankingDescription')}</p>
|
<p>{t('_missHai.rankingDescription')}</p>
|
||||||
</section>
|
</section>
|
||||||
<section className="pt-2">
|
<section className="pt-2">
|
||||||
<Ranking />
|
<Ranking />
|
||||||
</section>
|
</section>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -6,71 +6,71 @@ import { useAnnouncements } from '../hooks/useAnnouncements';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
export const IndexSessionPage: React.VFC = () => {
|
export const IndexSessionPage: React.VFC = () => {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
const { data: session } = useGetSessionQuery(undefined);
|
const { data: session } = useGetSessionQuery(undefined);
|
||||||
const score = useGetScoreQuery(undefined);
|
const score = useGetScoreQuery(undefined);
|
||||||
|
|
||||||
const announcements = useAnnouncements();
|
const announcements = useAnnouncements();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="fade">
|
<article className="fade">
|
||||||
<section>
|
<section>
|
||||||
<h2><i className="fas fa-bell"></i> {t('announcements')}</h2>
|
<h2><i className="fas fa-bell"></i> {t('announcements')}</h2>
|
||||||
<div className="large menu xmenu fade">
|
<div className="large menu xmenu fade">
|
||||||
{announcements.map(a => (
|
{announcements.map(a => (
|
||||||
<Link className="item fluid" key={a.id} to={`/announcements/${a.id}`}>
|
<Link className="item fluid" key={a.id} to={`/announcements/${a.id}`}>
|
||||||
{a.title}
|
{a.title}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<div className="misshaiPageLayout">
|
<div className="misshaiPageLayout">
|
||||||
<section className="misshaiData">
|
<section className="misshaiData">
|
||||||
<h2><i className="fas fa-chart-line"></i> {t('_missHai.data')}</h2>
|
<h2><i className="fas fa-chart-line"></i> {t('_missHai.data')}</h2>
|
||||||
<table className="table fluid">
|
<table className="table fluid">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th></th>
|
<th></th>
|
||||||
<th>{t('_missHai.dataScore')}</th>
|
<th>{t('_missHai.dataScore')}</th>
|
||||||
<th>{t('_missHai.dataDelta')}</th>
|
<th>{t('_missHai.dataDelta')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td>{t('notes')}</td>
|
<td>{t('notes')}</td>
|
||||||
<td>{score.data?.notesCount ?? '...'}</td>
|
<td>{score.data?.notesCount ?? '...'}</td>
|
||||||
<td>{score.data?.notesDelta ?? '...'}</td>
|
<td>{score.data?.notesDelta ?? '...'}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>{t('following')}</td>
|
<td>{t('following')}</td>
|
||||||
<td>{score.data?.followingCount ?? '...'}</td>
|
<td>{score.data?.followingCount ?? '...'}</td>
|
||||||
<td>{score.data?.followingDelta ?? '...'}</td>
|
<td>{score.data?.followingDelta ?? '...'}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>{t('followers')}</td>
|
<td>{t('followers')}</td>
|
||||||
<td>{score.data?.followersCount ?? '...'}</td>
|
<td>{score.data?.followersCount ?? '...'}</td>
|
||||||
<td>{score.data?.followersDelta ?? '...'}</td>
|
<td>{score.data?.followersDelta ?? '...'}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<p>
|
<p>
|
||||||
<strong>
|
<strong>
|
||||||
{t('_missHai.rating')}{': '}
|
{t('_missHai.rating')}{': '}
|
||||||
</strong>
|
</strong>
|
||||||
{session?.rating ?? '...'}
|
{session?.rating ?? '...'}
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
<section className="developerInfo">
|
<section className="developerInfo">
|
||||||
<h2><i className="fas fa-circle-question"></i> {t('_developerInfo.title')}</h2>
|
<h2><i className="fas fa-circle-question"></i> {t('_developerInfo.title')}</h2>
|
||||||
<p>{t('_developerInfo.description')}</p>
|
<p>{t('_developerInfo.description')}</p>
|
||||||
<div className="menu large">
|
<div className="menu large">
|
||||||
<a className="item" href="//mk.shrimpia.network/@Lutica" target="_blank" rel="noopener noreferrer">
|
<a className="item" href="//mk.shrimpia.network/@Lutica" target="_blank" rel="noopener noreferrer">
|
||||||
<i className="icon fas fa-at"></i>
|
<i className="icon fas fa-at"></i>
|
||||||
Lutica@mk.shrimpia.network
|
Lutica@mk.shrimpia.network
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { IndexSessionPage } from './index.session';
|
||||||
import { IndexWelcomePage } from './index.welcome';
|
import { IndexWelcomePage } from './index.welcome';
|
||||||
|
|
||||||
export const IndexPage: React.VFC = () => {
|
export const IndexPage: React.VFC = () => {
|
||||||
const token = localStorage[LOCALSTORAGE_KEY_TOKEN];
|
const token = localStorage[LOCALSTORAGE_KEY_TOKEN];
|
||||||
|
|
||||||
return token ? <IndexSessionPage /> : <IndexWelcomePage />;
|
return token ? <IndexSessionPage /> : <IndexWelcomePage />;
|
||||||
};
|
};
|
||||||
|
|
|
@ -67,69 +67,69 @@ const FormWrapper = styled.div`
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const IndexWelcomePage: React.VFC = () => {
|
export const IndexWelcomePage: React.VFC = () => {
|
||||||
const {isMobile} = useSelector(state => state.screen);
|
const {isMobile} = useSelector(state => state.screen);
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
|
|
||||||
const announcements = useAnnouncements();
|
const announcements = useAnnouncements();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Hero className="fluid shadow-2" isMobile={isMobile}>
|
<Hero className="fluid shadow-2" isMobile={isMobile}>
|
||||||
<div className="hero">
|
<div className="hero">
|
||||||
<h1 className="shadow-t font-misskey">{t('title')}</h1>
|
<h1 className="shadow-t font-misskey">{t('title')}</h1>
|
||||||
<p className="shadow-t">{t('description1')}</p>
|
<p className="shadow-t">{t('description1')}</p>
|
||||||
<p className="shadow-t">{t('description2')}</p>
|
<p className="shadow-t">{t('description2')}</p>
|
||||||
<FormWrapper className="bg-panel pa-2 mt-4 rounded shadow-2">
|
<FormWrapper className="bg-panel pa-2 mt-4 rounded shadow-2">
|
||||||
<LoginForm />
|
<LoginForm />
|
||||||
</FormWrapper>
|
</FormWrapper>
|
||||||
</div>
|
</div>
|
||||||
<div className="announcements">
|
<div className="announcements">
|
||||||
<h2><i className="fas fa-bell"></i> {t('announcements')}</h2>
|
<h2><i className="fas fa-bell"></i> {t('announcements')}</h2>
|
||||||
<div className="menu xmenu">
|
<div className="menu xmenu">
|
||||||
{announcements.map(a => (
|
{announcements.map(a => (
|
||||||
<Link className="item fluid" key={a.id} to={`/announcements/${a.id}`}>
|
<Link className="item fluid" key={a.id} to={`/announcements/${a.id}`}>
|
||||||
{a.title}
|
{a.title}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rects">
|
<div className="rects">
|
||||||
<div className="rect"></div>
|
<div className="rect"></div>
|
||||||
<div className="rect"></div>
|
<div className="rect"></div>
|
||||||
<div className="rect"></div>
|
<div className="rect"></div>
|
||||||
<div className="rect"></div>
|
<div className="rect"></div>
|
||||||
</div>
|
</div>
|
||||||
</Hero>
|
</Hero>
|
||||||
<Twemoji options={{className: 'twemoji'}}>
|
<Twemoji options={{className: 'twemoji'}}>
|
||||||
<div className="py-4 text-125 text-center">
|
<div className="py-4 text-125 text-center">
|
||||||
👍 ❤ 😆 🎉 🍮
|
👍 ❤ 😆 🎉 🍮
|
||||||
</div>
|
</div>
|
||||||
</Twemoji>
|
</Twemoji>
|
||||||
<article className="xarticle vstack pa-2">
|
<article className="xarticle vstack pa-2">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('_welcome.title')}</h2>
|
<h2>{t('_welcome.title')}</h2>
|
||||||
<p>{t('_welcome.description')}</p>
|
<p>{t('_welcome.description')}</p>
|
||||||
</header>
|
</header>
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<article className="col-4 col-12-sm">
|
<article className="col-4 col-12-sm">
|
||||||
<h3><i className="fas fa-bullhorn"/> {t('_welcome.misshaiAlertTitle')}</h3>
|
<h3><i className="fas fa-bullhorn"/> {t('_welcome.misshaiAlertTitle')}</h3>
|
||||||
<p>{t('_welcome.misshaiAlertDescription')}</p>
|
<p>{t('_welcome.misshaiAlertDescription')}</p>
|
||||||
</article>
|
</article>
|
||||||
<article className="col-4 col-12-sm">
|
<article className="col-4 col-12-sm">
|
||||||
<h3><i className="fas fa-ranking-star"/> {t('_missHai.ranking')}</h3>
|
<h3><i className="fas fa-ranking-star"/> {t('_missHai.ranking')}</h3>
|
||||||
<p>{t('_welcome.misshaiRankingDescription')}</p>
|
<p>{t('_welcome.misshaiRankingDescription')}</p>
|
||||||
<Link to="/apps/miss-hai/ranking">{t('_missHai.showRanking')}</Link>
|
<Link to="/apps/miss-hai/ranking">{t('_missHai.showRanking')}</Link>
|
||||||
</article>
|
</article>
|
||||||
<article className="col-4 col-12-sm">
|
<article className="col-4 col-12-sm">
|
||||||
<h3><i className="fas fa-crop-simple"/> {t('catAdjuster')}</h3>
|
<h3><i className="fas fa-crop-simple"/> {t('catAdjuster')}</h3>
|
||||||
<p>{t('_welcome.catAdjusterDescription')}</p>
|
<p>{t('_welcome.catAdjusterDescription')}</p>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
<article className="mt-5">
|
<article className="mt-5">
|
||||||
<h3>{t('_welcome.nextFeaturesTitle')}</h3>
|
<h3>{t('_welcome.nextFeaturesTitle')}</h3>
|
||||||
<p>{t('_welcome.nextFeaturesDescription')}</p>
|
<p>{t('_welcome.nextFeaturesDescription')}</p>
|
||||||
</article>
|
</article>
|
||||||
</article>
|
</article>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -34,148 +34,148 @@ const ColorInput = styled.input<{color: string}>`
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const SettingPage: React.VFC = () => {
|
export const SettingPage: React.VFC = () => {
|
||||||
const session = useGetSessionQuery(undefined);
|
const session = useGetSessionQuery(undefined);
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
const data = session.data;
|
const data = session.data;
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
|
|
||||||
useTitle('_sidebar.settings');
|
useTitle('_sidebar.settings');
|
||||||
|
|
||||||
const currentTheme = useSelector(state => state.screen.theme);
|
const currentTheme = useSelector(state => state.screen.theme);
|
||||||
const currentLang = useSelector(state => state.screen.language);
|
const currentLang = useSelector(state => state.screen.language);
|
||||||
const currentAccentColor = useSelector(state => state.screen.accentColor);
|
const currentAccentColor = useSelector(state => state.screen.accentColor);
|
||||||
|
|
||||||
const onClickLogout = useCallback(() => {
|
const onClickLogout = useCallback(() => {
|
||||||
dispatch(showModal({
|
dispatch(showModal({
|
||||||
type: 'dialog',
|
type: 'dialog',
|
||||||
title: t('_logout.title'),
|
title: t('_logout.title'),
|
||||||
message: t('_logout.message'),
|
message: t('_logout.message'),
|
||||||
icon: 'question',
|
icon: 'question',
|
||||||
buttons: [
|
buttons: [
|
||||||
{
|
{
|
||||||
text: t('_logout.yes'),
|
text: t('_logout.yes'),
|
||||||
style: 'primary',
|
style: 'primary',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: t('_logout.no'),
|
text: t('_logout.no'),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
onSelect(i) {
|
onSelect(i) {
|
||||||
if (i === 0) {
|
if (i === 0) {
|
||||||
localStorage.removeItem(LOCALSTORAGE_KEY_TOKEN);
|
localStorage.removeItem(LOCALSTORAGE_KEY_TOKEN);
|
||||||
location.href = '/';
|
location.href = '/';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
}, [dispatch, t]);
|
}, [dispatch, t]);
|
||||||
|
|
||||||
const onClickDeleteAccount = useCallback(() => {
|
const onClickDeleteAccount = useCallback(() => {
|
||||||
dispatch(showModal({
|
dispatch(showModal({
|
||||||
type: 'dialog',
|
type: 'dialog',
|
||||||
title: t('_deactivate.title'),
|
title: t('_deactivate.title'),
|
||||||
message: t('_deactivate.message'),
|
message: t('_deactivate.message'),
|
||||||
icon: 'question',
|
icon: 'question',
|
||||||
buttons: [
|
buttons: [
|
||||||
{
|
{
|
||||||
text: t('_deactivate.yes'),
|
text: t('_deactivate.yes'),
|
||||||
style: 'danger',
|
style: 'danger',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: t('_deactivate.no'),
|
text: t('_deactivate.no'),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
primaryClassName: 'danger',
|
primaryClassName: 'danger',
|
||||||
onSelect(i) {
|
onSelect(i) {
|
||||||
if (i === 0) {
|
if (i === 0) {
|
||||||
$delete('session').then(() => {
|
$delete('session').then(() => {
|
||||||
dispatch(showModal({
|
dispatch(showModal({
|
||||||
type: 'dialog',
|
type: 'dialog',
|
||||||
message: t('_deactivate.success'),
|
message: t('_deactivate.success'),
|
||||||
icon: 'info',
|
icon: 'info',
|
||||||
onSelect() {
|
onSelect() {
|
||||||
localStorage.removeItem(LOCALSTORAGE_KEY_TOKEN);
|
localStorage.removeItem(LOCALSTORAGE_KEY_TOKEN);
|
||||||
location.href = '/';
|
location.href = '/';
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
}).catch((e) => {
|
}).catch((e) => {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
dispatch(showModal({
|
dispatch(showModal({
|
||||||
type: 'dialog',
|
type: 'dialog',
|
||||||
message: t('_deactivate.failure'),
|
message: t('_deactivate.failure'),
|
||||||
icon: 'error',
|
icon: 'error',
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
}, [dispatch, t]);
|
}, [dispatch, t]);
|
||||||
|
|
||||||
return session.isLoading || !data ? (
|
return session.isLoading || !data ? (
|
||||||
<div className="skeleton" style={{width: '100%', height: '128px'}}></div>
|
<div className="skeleton" style={{width: '100%', height: '128px'}}></div>
|
||||||
) : (
|
) : (
|
||||||
<article className="fade">
|
<article className="fade">
|
||||||
<h2><i className="fas fa-palette"></i> {t('appearance')}</h2>
|
<h2><i className="fas fa-palette"></i> {t('appearance')}</h2>
|
||||||
<div className="vstack">
|
<div className="vstack">
|
||||||
<div className="card pa-2">
|
<div className="card pa-2">
|
||||||
<h3>{t('theme')}</h3>
|
<h3>{t('theme')}</h3>
|
||||||
<div className="vstack">
|
<div className="vstack">
|
||||||
{
|
{
|
||||||
themes.map(theme => (
|
themes.map(theme => (
|
||||||
<label key={theme} className="input-check">
|
<label key={theme} className="input-check">
|
||||||
<input type="radio" value={theme} checked={theme === currentTheme} onChange={(e) => dispatch(changeTheme(e.target.value as Theme))} />
|
<input type="radio" value={theme} checked={theme === currentTheme} onChange={(e) => dispatch(changeTheme(e.target.value as Theme))} />
|
||||||
<span>{t(`_themes.${theme}`)}</span>
|
<span>{t(`_themes.${theme}`)}</span>
|
||||||
</label>
|
</label>
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="card pa-2">
|
<div className="card pa-2">
|
||||||
<h3>{t('accentColor')}</h3>
|
<h3>{t('accentColor')}</h3>
|
||||||
<div className="hstack slim wrap mb-2">
|
<div className="hstack slim wrap mb-2">
|
||||||
{designSystemColors.map(c => (
|
{designSystemColors.map(c => (
|
||||||
<ColorInput className="shadow-2" type="radio" color={c} value={c} checked={c === currentAccentColor} onChange={e => dispatch(changeAccentColor(e.target.value))} />
|
<ColorInput className="shadow-2" type="radio" color={c} value={c} checked={c === currentAccentColor} onChange={e => dispatch(changeAccentColor(e.target.value))} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<button className="btn primary" onClick={() => dispatch(changeAccentColor('green'))}>{t('resetToDefault')}</button>
|
<button className="btn primary" onClick={() => dispatch(changeAccentColor('green'))}>{t('resetToDefault')}</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="card pa-2">
|
<div className="card pa-2">
|
||||||
<h3>{t('language')}</h3>
|
<h3>{t('language')}</h3>
|
||||||
<select name="currentLang" className="input-field" value={currentLang} onChange={(e) => dispatch(changeLang(e.target.value))}>
|
<select name="currentLang" className="input-field" value={currentLang} onChange={(e) => dispatch(changeLang(e.target.value))}>
|
||||||
{
|
{
|
||||||
(Object.keys(languageName) as Array<keyof typeof languageName>).map(n => (
|
(Object.keys(languageName) as Array<keyof typeof languageName>).map(n => (
|
||||||
<option value={n} key={n}>{languageName[n]}</option>
|
<option value={n} key={n}>{languageName[n]}</option>
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
<div className="alert bg-info mt-2">
|
<div className="alert bg-info mt-2">
|
||||||
<i className="icon fas fa-language" />
|
<i className="icon fas fa-language" />
|
||||||
<div>
|
<div>
|
||||||
{t('translatedByTheCommunity')}
|
{t('translatedByTheCommunity')}
|
||||||
<a href="https://crowdin.com/project/misskey-tools" target="_blank" rel="noopener noreferrer">{t('helpTranslation')}</a>
|
<a href="https://crowdin.com/project/misskey-tools" target="_blank" rel="noopener noreferrer">{t('helpTranslation')}</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<section>
|
<section>
|
||||||
<h2>{t('otherSettings')}</h2>
|
<h2>{t('otherSettings')}</h2>
|
||||||
<div className="list-form">
|
<div className="list-form">
|
||||||
<button className="item" onClick={onClickLogout}>
|
<button className="item" onClick={onClickLogout}>
|
||||||
<i className="icon fas fa-arrow-up-right-from-square" />
|
<i className="icon fas fa-arrow-up-right-from-square" />
|
||||||
<div className="body">
|
<div className="body">
|
||||||
<h1>{t('logout')}</h1>
|
<h1>{t('logout')}</h1>
|
||||||
<p className="desc">{t('logoutDescription')}</p>
|
<p className="desc">{t('logoutDescription')}</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<button className="item text-danger" onClick={onClickDeleteAccount}>
|
<button className="item text-danger" onClick={onClickDeleteAccount}>
|
||||||
<i className="icon fas fa-trash-can" />
|
<i className="icon fas fa-trash-can" />
|
||||||
<div className="body">
|
<div className="body">
|
||||||
<h1>{t('deleteAccount')}</h1>
|
<h1>{t('deleteAccount')}</h1>
|
||||||
<p className="desc">{t('deleteAccountDescription')}</p>
|
<p className="desc">{t('deleteAccountDescription')}</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,42 +1,43 @@
|
||||||
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
|
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
|
||||||
|
|
||||||
import { API_ENDPOINT, LOCALSTORAGE_KEY_TOKEN } from '../const';
|
import { API_ENDPOINT, LOCALSTORAGE_KEY_TOKEN } from '../const';
|
||||||
import { IUser } from '../../common/types/user';
|
import { IUser } from '../../common/types/user';
|
||||||
import { Score } from '../../common/types/score';
|
import { Score } from '../../common/types/score';
|
||||||
import { Meta } from '../../common/types/meta';
|
import { Meta } from '../../common/types/meta';
|
||||||
|
|
||||||
export const sessionApi = createApi({
|
export const sessionApi = createApi({
|
||||||
reducerPath: 'session',
|
reducerPath: 'session',
|
||||||
baseQuery: fetchBaseQuery({ baseUrl: API_ENDPOINT }),
|
baseQuery: fetchBaseQuery({ baseUrl: API_ENDPOINT }),
|
||||||
endpoints: (builder) => ({
|
endpoints: (builder) => ({
|
||||||
getSession: builder.query<IUser, undefined>({
|
getSession: builder.query<IUser, undefined>({
|
||||||
query: () => ({
|
query: () => ({
|
||||||
url: '/session/',
|
url: '/session/',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${localStorage[LOCALSTORAGE_KEY_TOKEN]}`,
|
'Authorization': `Bearer ${localStorage[LOCALSTORAGE_KEY_TOKEN]}`,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
getScore: builder.query<Score, undefined>({
|
getScore: builder.query<Score, undefined>({
|
||||||
query: () => ({
|
query: () => ({
|
||||||
url: '/session/score',
|
url: '/session/score',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${localStorage[LOCALSTORAGE_KEY_TOKEN]}`,
|
'Authorization': `Bearer ${localStorage[LOCALSTORAGE_KEY_TOKEN]}`,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
getMeta: builder.query<Meta, undefined>({
|
getMeta: builder.query<Meta, undefined>({
|
||||||
query: () => ({
|
query: () => ({
|
||||||
url: '/meta',
|
url: '/meta',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${localStorage[LOCALSTORAGE_KEY_TOKEN]}`,
|
'Authorization': `Bearer ${localStorage[LOCALSTORAGE_KEY_TOKEN]}`,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const {
|
export const {
|
||||||
useGetSessionQuery,
|
useGetSessionQuery,
|
||||||
useGetScoreQuery,
|
useGetScoreQuery,
|
||||||
useGetMetaQuery,
|
useGetMetaQuery,
|
||||||
} = sessionApi;
|
} = sessionApi;
|
||||||
|
|
|
@ -5,13 +5,13 @@ import { sessionApi } from '../services/session';
|
||||||
import ScreenReducer from './slices/screen';
|
import ScreenReducer from './slices/screen';
|
||||||
|
|
||||||
export const store = configureStore({
|
export const store = configureStore({
|
||||||
reducer: {
|
reducer: {
|
||||||
[sessionApi.reducerPath]: sessionApi.reducer,
|
[sessionApi.reducerPath]: sessionApi.reducer,
|
||||||
screen: ScreenReducer,
|
screen: ScreenReducer,
|
||||||
},
|
},
|
||||||
|
|
||||||
middleware: (defaultMiddleware) => defaultMiddleware()
|
middleware: (defaultMiddleware) => defaultMiddleware()
|
||||||
.concat(sessionApi.middleware),
|
.concat(sessionApi.middleware),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type RootState = ReturnType<typeof store.getState>;
|
export type RootState = ReturnType<typeof store.getState>;
|
||||||
|
|
|
@ -9,71 +9,71 @@ import { IUser } from '../../../common/types/user';
|
||||||
import { DesignSystemColor } from '../../../common/types/design-system-color';
|
import { DesignSystemColor } from '../../../common/types/design-system-color';
|
||||||
|
|
||||||
interface ScreenState {
|
interface ScreenState {
|
||||||
modal: Modal | null;
|
modal: Modal | null;
|
||||||
modalShown: boolean;
|
modalShown: boolean;
|
||||||
theme: Theme;
|
theme: Theme;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
language: string;
|
language: string;
|
||||||
accentColor: DesignSystemColor;
|
accentColor: DesignSystemColor;
|
||||||
accounts: IUser[];
|
accounts: IUser[];
|
||||||
accountTokens: string[];
|
accountTokens: string[];
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
isDrawerShown: boolean;
|
isDrawerShown: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: ScreenState = {
|
const initialState: ScreenState = {
|
||||||
modal: null,
|
modal: null,
|
||||||
modalShown: false,
|
modalShown: false,
|
||||||
theme: localStorage[LOCALSTORAGE_KEY_THEME] ?? 'system',
|
theme: localStorage[LOCALSTORAGE_KEY_THEME] ?? 'system',
|
||||||
language: localStorage[LOCALSTORAGE_KEY_LANG] ?? i18n.language ?? 'ja_JP',
|
language: localStorage[LOCALSTORAGE_KEY_LANG] ?? i18n.language ?? 'ja_JP',
|
||||||
accentColor: localStorage[LOCALSTORAGE_KEY_ACCENT_COLOR] ?? 'green',
|
accentColor: localStorage[LOCALSTORAGE_KEY_ACCENT_COLOR] ?? 'green',
|
||||||
title: null,
|
title: null,
|
||||||
accounts: [],
|
accounts: [],
|
||||||
accountTokens: JSON.parse(localStorage.getItem(LOCALSTORAGE_KEY_ACCOUNTS) || '[]') as string[],
|
accountTokens: JSON.parse(localStorage.getItem(LOCALSTORAGE_KEY_ACCOUNTS) || '[]') as string[],
|
||||||
isMobile: false,
|
isMobile: false,
|
||||||
isDrawerShown: false,
|
isDrawerShown: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 値を設定するReducerを生成します。
|
* 値を設定するReducerを生成します。
|
||||||
*/
|
*/
|
||||||
const generateSetter = <T extends keyof WritableDraft<ScreenState>>(key: T, callback?: (state: WritableDraft<ScreenState>, action: PayloadAction<ScreenState[T]>) => void) => {
|
const generateSetter = <T extends keyof WritableDraft<ScreenState>>(key: T, callback?: (state: WritableDraft<ScreenState>, action: PayloadAction<ScreenState[T]>) => void) => {
|
||||||
return (state: WritableDraft<ScreenState>, action: PayloadAction<ScreenState[T]>) => {
|
return (state: WritableDraft<ScreenState>, action: PayloadAction<ScreenState[T]>) => {
|
||||||
state[key] = action.payload;
|
state[key] = action.payload;
|
||||||
if (callback) callback(state, action);
|
if (callback) callback(state, action);
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const screenSlice = createSlice({
|
export const screenSlice = createSlice({
|
||||||
name: 'screen',
|
name: 'screen',
|
||||||
initialState,
|
initialState,
|
||||||
reducers: {
|
reducers: {
|
||||||
showModal: (state, action: PayloadAction<Modal>) => {
|
showModal: (state, action: PayloadAction<Modal>) => {
|
||||||
state.modal = action.payload;
|
state.modal = action.payload;
|
||||||
state.modalShown = true;
|
state.modalShown = true;
|
||||||
},
|
},
|
||||||
hideModal: (state) => {
|
hideModal: (state) => {
|
||||||
state.modal = null;
|
state.modal = null;
|
||||||
state.modalShown = false;
|
state.modalShown = false;
|
||||||
},
|
},
|
||||||
changeTheme: generateSetter('theme', (_, action) => {
|
changeTheme: generateSetter('theme', (_, action) => {
|
||||||
localStorage[LOCALSTORAGE_KEY_THEME] = action.payload;
|
localStorage[LOCALSTORAGE_KEY_THEME] = action.payload;
|
||||||
}),
|
}),
|
||||||
changeLang: generateSetter('language', (_, action) => {
|
changeLang: generateSetter('language', (_, action) => {
|
||||||
localStorage[LOCALSTORAGE_KEY_LANG] = action.payload;
|
localStorage[LOCALSTORAGE_KEY_LANG] = action.payload;
|
||||||
i18n.changeLanguage(action.payload);
|
i18n.changeLanguage(action.payload);
|
||||||
}),
|
}),
|
||||||
changeAccentColor: generateSetter('accentColor', (_, action) => {
|
changeAccentColor: generateSetter('accentColor', (_, action) => {
|
||||||
localStorage[LOCALSTORAGE_KEY_ACCENT_COLOR] = action.payload;
|
localStorage[LOCALSTORAGE_KEY_ACCENT_COLOR] = action.payload;
|
||||||
}),
|
}),
|
||||||
setAccounts: generateSetter('accounts', (state, action) => {
|
setAccounts: generateSetter('accounts', (state, action) => {
|
||||||
state.accountTokens = action.payload.map(a => a.misshaiToken);
|
state.accountTokens = action.payload.map(a => a.misshaiToken);
|
||||||
localStorage[LOCALSTORAGE_KEY_ACCOUNTS] = JSON.stringify(state.accountTokens);
|
localStorage[LOCALSTORAGE_KEY_ACCOUNTS] = JSON.stringify(state.accountTokens);
|
||||||
}),
|
}),
|
||||||
setMobile: generateSetter('isMobile'),
|
setMobile: generateSetter('isMobile'),
|
||||||
setTitle: generateSetter('title'),
|
setTitle: generateSetter('title'),
|
||||||
setDrawerShown: generateSetter('isDrawerShown'),
|
setDrawerShown: generateSetter('isDrawerShown'),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const { showModal, hideModal, changeTheme, changeLang, changeAccentColor, setAccounts, setMobile, setTitle, setDrawerShown } = screenSlice.actions;
|
export const { showModal, hideModal, changeTheme, changeLang, changeAccentColor, setAccounts, setMobile, setTitle, setDrawerShown } = screenSlice.actions;
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue