0
0
Fork 0

ES Modulesに移行

This commit is contained in:
Xeltica 2023-02-25 17:13:07 +09:00
parent 0c3df4245d
commit 69212dd99a
105 changed files with 3154 additions and 3230 deletions

View file

@ -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();
})();

View file

@ -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;

View file

@ -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();
}
}

View file

@ -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;
}
}

View file

@ -1,4 +1,4 @@
export class AnnounceCreate {
title: string;
body: string;
title: string;
body: string;
}

View file

@ -1,5 +1,5 @@
export class AnnounceUpdate {
id: number;
title: string;
body: string;
id: number;
title: string;
body: string;
}

View file

@ -1,3 +1,3 @@
export class IdProp {
id: number;
id: number;
}

View file

@ -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;
}

View 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,
];

View file

@ -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,
};
}
}

View file

@ -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,
};
}
}

View file

@ -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);
}
}

View file

@ -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 });
};

View file

@ -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}`;
};

View file

@ -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;
};

View file

@ -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),
};
};

View file

@ -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();
};

View file

@ -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,
});
};

View file

@ -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,
});
};

View file

@ -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();
};

View file

@ -1,6 +1,6 @@
export interface Count
{
notesCount: number;
followingCount: number;
followersCount: number;
notesCount: number;
followingCount: number;
followersCount: number;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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);

View file

@ -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 },
});

View file

@ -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 });
}

View file

@ -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);
};

View file

@ -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,
});
};

View file

@ -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 truefalse
*/
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;
}

View file

@ -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;
}
};

View file

@ -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}人) へのアラート送信が完了しました。`);
};

View file

@ -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);
};

View file

@ -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[]>())
);
};

View file

@ -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('');
};

View file

@ -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';
};

View file

@ -1,8 +1,8 @@
export const alertModes = [
'note',
'notification',
'both',
'nothing'
'note',
'notification',
'both',
'nothing'
] as const;
export type AlertMode = typeof alertModes[number];

View file

@ -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;
}

View file

@ -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];

View file

@ -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];

View file

@ -1,5 +1,5 @@
export type Log = {
text: string;
level: 'error' | 'warn' | 'info';
timestamp: Date;
text: string;
level: 'error' | 'warn' | 'info';
timestamp: Date;
}

View file

@ -1,4 +1,4 @@
export interface Meta {
version: string;
currentTokenVersion: number;
version: string;
currentTokenVersion: number;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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];

View file

@ -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;
};

View file

@ -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>
);

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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>
</>
);
};

View file

@ -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>
);
};

View file

@ -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" />;
};

View file

@ -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>
</>
);
};

View file

@ -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>
</>
);
};

View file

@ -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>
);
};

View file

@ -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>
</>
);
};

View file

@ -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
);
};

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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;
};

View file

@ -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]);
};

View file

@ -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'));

View file

@ -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';
};

View file

@ -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));
};

View file

@ -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]);
};

View file

@ -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: 'いいえ',
};

View file

@ -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;
}

View file

@ -2,6 +2,6 @@ import { ModalTypeDialog } from './dialog';
import { ModalTypeMenu } from './menu';
export type Modal =
| ModalTypeMenu
| ModalTypeDialog;
| ModalTypeMenu
| ModalTypeDialog;

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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" />&nbsp;
{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" />&nbsp;
{dayjs(announcement.createdAt).locale(lang.split('_')[0]).fromNow()}
</aside>
</h2>
<section>
<ReactMarkdown>{announcement.body}</ReactMarkdown>
</section>
</article>
);
};

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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 />;
};

View file

@ -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">
👍&emsp;&emsp;😆&emsp;🎉&emsp;🍮
</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>
</>
);
};

View file

@ -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')}&nbsp;
<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')}&nbsp;
<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>
);
};

View file

@ -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;

View file

@ -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>;

View file

@ -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;

View file

@ -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();
})();

View file

@ -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);
}
};