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